grid-rules

Define grid colors, filters, and sort order as plain objects a desk can change without a build.

import { applyRules, describeRule, type ColumnRule, type RuleColumn } from "@/lib/grid-rules"

interface Request {
  id: string
  size: number
}

const columns: RuleColumn<Request>[] = [{ key: "size", header: "Size", numeric: true, accessor: (row) => row.size }]
const rule: ColumnRule = { id: "large", column: "size", when: { op: "gte", value: "10,000,000" }, tone: "primary" }
const applied = applyRules([rule], columns)
const rows: Request[] = [{ id: "Q-1", size: 5_000_000 }, { id: "Q-2", size: 10_000_000 }]

export default function GridRulesDemo() {
  return (
    <div className="w-fit max-w-full space-y-3 text-xs lining-nums tabular-nums">
      <p>{describeRule(rule, columns)}</p>
      <table className="text-left">
        <thead className="text-muted-foreground">
          <tr><th scope="col" className="px-2 py-1 font-medium">Request</th><th scope="col" className="px-2 py-1 text-right font-medium">Size</th><th scope="col" className="px-2 py-1 font-medium">Rule</th></tr>
        </thead>
        <tbody>
          {rows.map((row) => {
            const decoration = applied.cell("size", row)
            return (
              <tr key={row.id} className="border-t border-border">
                <th scope="row" className="px-2 py-1 font-normal">{row.id}</th>
                <td {...decoration} className={`px-2 py-1 text-right ${decoration?.className ?? ""}`}>{row.size.toLocaleString("en-US")}</td>
                <td className="px-2 py-1">{decoration ? "Matched" : "No match"}</td>
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/grid-rules#v1.4.13
npx shadcn@latest add tradecn/ui/grid-rules#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/grid-rules#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/grid-rules#v1.4.13

Usage

import { applyRules, describeRule, type ColumnRule, type RuleColumn } from "@/lib/grid-rules"

interface Request {
  id: string
  size: number
}

const columns: RuleColumn<Request>[] = [{ key: "size", header: "Size", numeric: true, accessor: (row) => row.size }]
const rule: ColumnRule = { id: "large", column: "size", when: { op: "gte", value: "10,000,000" }, tone: "primary" }
const applied = applyRules([rule], columns)
const rows: Request[] = [{ id: "Q-1", size: 5_000_000 }, { id: "Q-2", size: 10_000_000 }]

function SizeRule() {
  return (
    <div className="w-fit max-w-full space-y-3 text-xs lining-nums tabular-nums">
      <p>{describeRule(rule, columns)}</p>
      <table className="text-left">
        <thead className="text-muted-foreground">
          <tr><th scope="col" className="px-2 py-1 font-medium">Request</th><th scope="col" className="px-2 py-1 text-right font-medium">Size</th><th scope="col" className="px-2 py-1 font-medium">Rule</th></tr>
        </thead>
        <tbody>
          {rows.map((row) => {
            const decoration = applied.cell("size", row)
            return (
              <tr key={row.id} className="border-t border-border">
                <th scope="row" className="px-2 py-1 font-normal">{row.id}</th>
                <td {...decoration} className={`px-2 py-1 text-right ${decoration?.className ?? ""}`}>{row.size.toLocaleString("en-US")}</td>
                <td className="px-2 py-1">{decoration ? "Matched" : "No match"}</td>
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

applyRules compiles the rule against the column's accessor. A matching cell receives its decoration; a nonmatching cell receives undefined. Spread the decoration onto your cell and keep its className when adding your own styles. The returned attributes include the rule id, tone, and accessible description.

The numeric column reads the string 10,000,000 as a number. This example uses only the GridRules library; the DataGrid composition below handles the decorations for you.

Filtering and sorting

Compile a filter and comparator when you work with rows outside DataGrid. Here the filter keeps requests of at least 2,000,000. Sorting groups by status first, then orders each group from largest to smallest. Turn either control off to compare the result with the original rows and source order.

The rules and columns are fixed, so their functions are compiled once. Filtering or copying produces a new array before sorting; the source rows keep their order.

import { useState } from "react"
import { compileComparator, compileFilter, type RuleColumn } from "@/lib/grid-rules"

interface Request {
  id: string
  size: number
  status: string
}

const columns: RuleColumn<Request>[] = [
  { key: "size", header: "Size", numeric: true, accessor: (row) => row.size },
  { key: "status", header: "Status", accessor: (row) => row.status },
]
const matches = compileFilter([{ column: "size", op: "gte", value: "2,000,000" }], columns)
const compare = compileComparator([{ key: "status", dir: "asc" }, { key: "size", dir: "desc" }], columns)
const requests: Request[] = [
  { id: "Q-1", size: 5_000_000, status: "Open" },
  { id: "Q-2", size: 10_000_000, status: "Quoted" },
  { id: "Q-3", size: 1_000_000, status: "Open" },
  { id: "Q-4", size: 15_000_000, status: "Open" },
  { id: "Q-5", size: 2_000_000, status: "Done away" },
]

export default function GridRulesFilterSortDemo() {
  const [filtered, setFiltered] = useState(true)
  const [sorted, setSorted] = useState(true)
  const rows = filtered ? requests.filter(matches) : [...requests]
  if (sorted && compare) rows.sort(compare)

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-3 text-xs lining-nums tabular-nums">
        <label className="flex items-center gap-1"><input type="checkbox" checked={filtered} onChange={(event) => setFiltered(event.target.checked)} />Minimum size: 2,000,000</label>
        <label className="flex items-center gap-1"><input type="checkbox" checked={sorted} onChange={(event) => setSorted(event.target.checked)} />Sort by status, then size</label>
      </div>
      <div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
        <table className="w-full text-left">
          <thead className="text-muted-foreground">
            <tr><th scope="col" className="px-2 py-1 font-medium">Request</th><th scope="col" className="px-2 py-1 text-right font-medium">Size</th><th scope="col" className="px-2 py-1 font-medium">Status</th></tr>
          </thead>
          <tbody>
            {rows.map((row) => (
              <tr key={row.id} className="border-t border-border">
                <th scope="row" className="px-2 py-1 font-normal">{row.id}</th><td className="px-2 py-1 text-right">{row.size.toLocaleString("en-US")}</td><td className="px-2 py-1">{row.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <p role="status" className="text-muted-foreground">Showing {rows.length} of {requests.length} requests. {sorted ? "Order: status, then largest size." : "Source order."}</p>
      </div>
    </>
  )
}

DataGrid highlights

Install data-grid as well; it supplies the grid, row store, and format helpers used here. Pass a GridRules object to its rules prop and the grid compiles it. This example isolates highlights from filtering and sorting.

The price column's parser reads rule values such as 100-00 in 32nds. Cell rules mark rich and cheap prices; row rules mark large requests and requests done away. Q-2 matches a price rule and a row rule independently. Q-3 is small enough to reach the done-away rule; if it also matched the earlier large rule, that row rule would win. Q-4 has no price, so neither price comparison matches.

Toggle Show highlights to compare the decorations. The descriptions below the grid state each condition and its target. The preview alignment controls keep an edge fixed while resizing columns, and save that choice across examples.

import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import { describeRule, type GridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Request {
  id: string
  size: number
  price: number | null
  status: string
}

const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Request>[] = [
  { key: "id", header: "Request", width: 96, accessor: (row) => row.id },
  { key: "size", header: "Size", width: 96, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
  { key: "price", header: "Price", width: 88, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number | null), parse: note.parsePrice },
  { key: "status", header: "Status", width: 104, accessor: (row) => row.status },
]
const rules: GridRules = {
  columns: [
    { id: "rich", column: "price", when: { op: "gte", value: "100-00" }, tone: "up", label: "Rich to the market" },
    { id: "cheap", column: "price", when: { op: "lt", value: "99-16" }, tone: "down", label: "Cheap to the market" },
    { id: "large", column: "size", when: { op: "gte", value: "10,000,000" }, tone: "primary", target: "row", label: "Large" },
    { id: "gone", column: "status", when: { op: "eq", value: "Done away" }, tone: "stale", target: "row" },
  ],
}
const requests: Request[] = [
  { id: "Q-1", size: 5_000_000, price: 99.5, status: "Open" },
  { id: "Q-2", size: 25_000_000, price: 100.015625, status: "Open" },
  { id: "Q-3", size: 2_000_000, price: 99.25, status: "Done away" },
  { id: "Q-4", size: 3_000_000, price: null, status: "Quoted" },
]

export default function GridRulesDataGridDemo() {
  const [enabled, setEnabled] = useState(true)
  const [store] = useState(() => {
    const store = createRowStore<Request>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: requests })
    return store
  })

  return (
    <>
      <div data-demo-controls className="text-xs"><label className="flex items-center gap-1"><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />Show highlights</label></div>
      <div className="w-fit max-w-full space-y-3 text-xs lining-nums tabular-nums">
        <div className="h-40"><DataGrid store={store} columns={columns} preset="rfq" label="Request highlights" rules={enabled ? rules : undefined} /></div>
        <ul role="list" aria-label="Highlight rules" className="space-y-1 text-muted-foreground">
          {rules.columns!.map((rule) => <li key={rule.id}>{describeRule(rule, columns)} ({rule.target ?? "cell"})</li>)}
        </ul>
      </div>
    </>
  )
}

API Reference

What a rule is

A rule names a column by key and reads each row through its accessor. It compares the underlying value, before the cell's display formatting.

GridRules groups three optional lists:

Field Type When omitted Purpose
columns ColumnRule[] No rule decorations Color cells or rows that match.
filter FilterRule[] No rule filtering Keep rows that match every filter rule.
sort SortRule[] No rule ordering Order by the first rule that distinguishes two rows.

ColumnRule describes a highlight:

Field Type Required / default Purpose
id string Required Identifies the applied rule in data-rule.
column string Required Key of the column to read.
when RuleCondition Required Condition that triggers the highlight.
tone RuleTone Required Token used for the text and background tint.
label string Generated description Accessible description; a blank label also uses the generated words.
target "cell" | "row" "cell" Paint the cell in column or the whole row.

The first matching cell rule wins for each column. The first matching row rule wins for the row. Cell and row rules apply independently.

RuleCondition contains the operator and its inputs. FilterRule adds column:

Field Type Required / default Purpose
column string Required on FilterRule Key of the column to read.
op RuleOp Required Operator from the table below.
value RuleValue Omitted Single comparison value, where the operator needs one.
values RuleValue[] Omitted Range endpoints for between, or candidates for in.

RuleValue is string | number | boolean | null. Strings use the column's parser; numbers and booleans are already typed.

SortRule has two required fields:

Field Type Purpose
key string Key of the column to read.
dir "asc" | "desc" Ascending or descending order; missing values stay last either way.

Operators

Operator Input Matches Offered by opsFor
eq value Equal value All columns
ne value Unequal, nonmissing value All columns
gt value Above Numeric columns
gte value At or above Numeric columns
lt value Below Numeric columns
lte value At or below Numeric columns
between values: [low, high] Within the range, including both ends Numeric columns
in values Equal to any candidate All columns
contains value Contains the text Other columns
startsWith value Starts with the text Other columns
isNull None Missing value All columns
notNull None Nonmissing value All columns

For eq, ne, and in, two strings compare without regard to case; a string and a number compare after converting the string to a number. contains and startsWith convert both sides to lowercase text. Ordering comparisons and sorting use numeric comparison when both sides are numbers, otherwise String(a).localeCompare(String(b)).

null, undefined, NaN, and infinities count as missing. A missing row value matches only isNull; it fails notNull and every other operator, including ne. An empty string is a value, not a missing value.

Helper Result
RULE_OPS All twelve operators.
NUMBER_OPS, TEXT_OPS The numeric and other-column choices listed above.
RULE_OP_LABELS Human-readable wording for each operator.
opsFor(column) NUMBER_OPS when column.numeric is true, otherwise TEXT_OPS, including for undefined. This is an editor choice list; compilation does not restrict operators by column kind.
columnName(column, key?) A nonblank string header, otherwise the column key. Without a column, uses key or "".
describeRule(rule, columns) Words for a ColumnRule or FilterRule, using values as typed: Price above 99-16+, Client one of ALPHA, BETA, Status is empty.

Values are typed in the column's format

RuleColumn<T> supplies what the helpers need from a column. data-grid's ColumnDef<T> satisfies this interface.

Field Type Required / default Purpose
key string Required Name used by rules.
header unknown Falls back to key A nonblank string names the column in descriptions and problems.
accessor (row: T) => unknown Required Reads the row value to compare.
numeric boolean false Selects numeric parsing and the numeric operator list.
parse (text: string) => unknown Numeric or text fallback Reads a string entered in a rule.

readRuleValue(column, value) accepts a RuleColumn<T> or undefined, and a RuleValue or undefined:

Input Reading
String with a column parse Calls parse(text) and normalizes its result.
String on a numeric column without parse Trims whitespace, removes commas, replaces Unicode minus (−) with -, then calls Number. Empty or invalid numeric text becomes null.
Any other string Keeps the text, including "".
Number or boolean Keeps the value, except nonfinite numbers become null.
null or undefined Returns null.

normalizeValue(value) maps null, undefined, NaN, and infinities to null, leaving other values unchanged. A custom parse should return a missing value when it cannot read the text; thrown errors are not caught.

For a 32nds price column, use parse: (text) => parsePrice(text, convention) to accept 99-16+. A numeric size column accepts 5,000,000 without a custom parser. For a boolean column, parse: (text) => text === "yes" makes yes true and every other string false.

If a required scalar comparison value parses to null, the condition matches no rows. As a highlight, it colors nothing; as a filter, it excludes every row. For in and between, compilation drops values that parse to null: in uses the remaining candidates, and between uses the first two remaining values, low then high. No candidates, or fewer than two endpoints, matches nothing.

ruleProblem(rule, columns) accepts a ColumnRule or FilterRule and returns a problem sentence or null. Use it to show missing columns, missing or unreadable inputs, or a range without exactly two endpoints. It checks the supplied rule separately; compilation does not call it. For example, an in list containing one readable and one unreadable numeric value reports a problem but still compiles to match the readable value.

Tones are tokens

RuleTone is a token name: up, down, flat, stale, expiring, primary, or destructive. RULE_TONES lists them; RULE_TONE_CLASS maps each to its CSS classes.

The text uses the token color. The background uses its soft variant, or a 12% tint for primary and destructive. This tint is a background image: a frozen cell keeps its opaque background, and a selected row's highlight shows through.

ruleDecoration(rule, columns) returns a RuleDecoration for a ColumnRule, without checking whether it matches:

Field Type Value
data-rule string Rule id.
data-tone RuleTone Rule tone.
aria-description string Trimmed label, or describeRule(rule, columns) when the label is absent or blank.
className string RULE_TONE_CLASS[rule.tone].

These attributes carry the rule's meaning alongside its color for tests and assistive technology. In DataGrid, an edit rejection takes precedence over the cell's rule description, and your getRowProps can override the row's description and data attributes.

Compiling

The compilation helpers take RuleColumn<T> columns. Functions that take rule lists accept readonly rule and column arrays and skip rules naming absent columns.

Function Input Result
compileCondition(condition, column) One RuleCondition and one column (row: T) => boolean.
compileFilter(rules, columns) FilterRule[] (row: T) => boolean; all compiled conditions must hold. With no rules naming existing columns, every row passes.
compileComparator(rules, columns) SortRule[] (a: T, b: T) => number, or undefined with no rules naming existing columns. Tries rules in order; returns 0 when all tie.
applyRules(rules, columns) ColumnRule[] AppliedRules<T>, described below.
compareValues(a, b) Two values of type unknown Numeric comparison for two numbers, text comparison otherwise; missing values last.
compareDirected(a, b, dir) Two values and "asc" or "desc" The same comparison in the requested direction, with missing values still last.

Compilation parses condition inputs before evaluating rows. Compiled conditions read row values through the column's accessor as needed. Recompile when rules or columns change.

AppliedRules<T> provides:

Member Type Result
cell (columnKey: string, row: T) => RuleDecoration | undefined First matching cell rule for this column.
getRowProps (row: T) => RuleDecoration | undefined First matching row rule.
byColumn ReadonlyMap<string, readonly ColumnRule[]> All rules naming known columns, grouped by column key in input order, including row rules.

DataGrid compiles its rules prop itself. Its header sort takes precedence, with rule sorting breaking ties; its filter callback must pass along with rule filters. With a custom view, the grid ignores rule filtering and sorting but still applies decorations. Keep rule arrays and columns stable between renders until they change.

Use the helpers directly for your own grid, a custom view, or rfq-stack's useRfqStackView.

What it does not do

The helpers compare row values with rule values. Your application owns storage and permissions; the rules are plain JSON data you can save or share.

Tokens

The install adds the up, down, flat, stale, and expiring tokens with their soft variants, if you do not have them. primary and destructive are shadcn's own.

This page is docs/grid-rules.md at v1.4.13.