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
-
Copy the files into your project:
lib/grid-rules.ts // Rules as data for a grid: which cells to color, which rows to show, and the order, as plain objects // a desk's support staff can write without a build, compiled here into the functions the grid runs. // // A rule names a column by its key and reads a row through that column's own accessor. A value typed // into a rule is read in the column's own format through its `parse`, so a price rule on a 32nds // column is written the way the column prints, "99-16+". Tones are token names, never a literal // color, and an applied rule carries its meaning in a channel besides the color: `data-rule` names // the rule on the element and the rule's words go in the element's accessible description (contract // rule 15). Nothing here decides a value; it only compares what the row says with what the rule says. export type RuleOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "between" | "in" | "contains" | "startsWith" | "isNull" | "notNull" /** What a rule compares against, as typed. A string is read in the column's format; a number or boolean is used as it is. */ export type RuleValue = string | number | boolean | null /** A token name. The soft variant tints the background; the token itself colors the text. */ export type RuleTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export interface RuleCondition { op: RuleOp /** For eq, ne, gt, gte, lt, lte, contains, and startsWith. */ value?: RuleValue /** For between (two: low, then high, both inclusive) and in (any number). Each is read as `value` is. */ values?: RuleValue[] } export interface ColumnRule { id: string /** The column whose value the rule reads, by key. */ column: string when: RuleCondition tone: RuleTone /** The rule in words: the accessible description of what it paints. `describeRule` writes one when it is left out. */ label?: string /** What the tone paints: the cell in `column` (default), or the whole row. */ target?: "cell" | "row" } export interface FilterRule extends RuleCondition { column: string } export interface SortRule { key: string dir: "asc" | "desc" } export interface GridRules { /** Cells and rows to color. The first rule in the list that matches a cell decides it. */ columns?: ColumnRule[] /** Every rule has to hold for a row to show. */ filter?: FilterRule[] /** The first rule that tells two rows apart decides their order. */ sort?: SortRule[] } /** What a rule needs of a column. The grid's `ColumnDef` satisfies it. */ export interface RuleColumn<T> { key: string /** Named in a rule's words when it is a string; the key otherwise. */ header?: unknown accessor: (row: T) => unknown numeric?: boolean /** Reads a value typed into a rule in this column's own format. Default: a number for a numeric column, the text itself for the rest. */ parse?: (text: string) => unknown } export const RULE_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "contains", "startsWith", "isNull", "notNull"] export const NUMBER_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "isNull", "notNull"] export const TEXT_OPS: readonly RuleOp[] = ["eq", "ne", "contains", "startsWith", "in", "isNull", "notNull"] /** The word for each op, as a rule reads aloud: "Price above 99-16+". */ export const RULE_OP_LABELS: Record<RuleOp, string> = { eq: "is", ne: "is not", gt: "above", gte: "at or above", lt: "below", lte: "at or below", between: "between", in: "one of", contains: "contains", startsWith: "starts with", isNull: "is empty", notNull: "is not empty", } export const RULE_TONES: readonly RuleTone[] = ["up", "down", "flat", "stale", "expiring", "primary", "destructive"] // The text takes the token and the background a tint of its soft variant, painted as a background // image rather than a background color so it layers over whatever color the element already has: a // frozen cell keeps its opaque `bg-background` under the tint and stays opaque as rows scroll beneath // it, and a selected row's `bg-accent` shows through. Literal strings, so Tailwind finds them. export const RULE_TONE_CLASS: Record<RuleTone, string> = { up: "text-up bg-[linear-gradient(var(--up-soft),var(--up-soft))]", down: "text-down bg-[linear-gradient(var(--down-soft),var(--down-soft))]", flat: "text-flat bg-[linear-gradient(var(--flat-soft),var(--flat-soft))]", stale: "text-stale bg-[linear-gradient(var(--stale-soft),var(--stale-soft))]", expiring: "text-expiring bg-[linear-gradient(var(--expiring-soft),var(--expiring-soft))]", primary: "text-primary bg-[linear-gradient(color-mix(in_oklab,var(--primary)_12%,transparent),color-mix(in_oklab,var(--primary)_12%,transparent))]", destructive: "text-destructive bg-[linear-gradient(color-mix(in_oklab,var(--destructive)_12%,transparent),color-mix(in_oklab,var(--destructive)_12%,transparent))]", } /** What an applied rule puts on a cell or a row. */ export interface RuleDecoration { "data-rule": string "data-tone": RuleTone "aria-description": string className: string } /** The ops a column offers: comparisons and ranges for a numeric column, text matching for the rest. */ export function opsFor(column: { numeric?: boolean } | undefined): readonly RuleOp[] { return column?.numeric ? NUMBER_OPS : TEXT_OPS } /** The column's name in words: its header when that is a string, else its key. */ export function columnName<T>(column: RuleColumn<T> | undefined, key?: string): string { if (column && typeof column.header === "string" && column.header.trim()) return column.header return column?.key ?? key ?? "" } /** Null for null, undefined, NaN, and the infinities; the value otherwise. */ export function normalizeValue(value: unknown): unknown { if (value === null || value === undefined) return null if (typeof value === "number" && !Number.isFinite(value)) return null return value } /** Nulls last, numbers numerically, everything else as text. */ export function compareValues(a: unknown, b: unknown): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an && bn) return 0 if (an) return 1 if (bn) return -1 if (typeof a === "number" && typeof b === "number") return a - b return String(a).localeCompare(String(b)) } /** `compareValues` in a direction, with a null last whichever way the list runs: a missing price never leads a list sorted high to low. */ export function compareDirected(a: unknown, b: unknown, dir: "asc" | "desc"): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an || bn) return compareValues(a, b) const c = compareValues(a, b) return dir === "desc" ? -c : c } /** * A value as typed into a rule, read for a column: a string goes through the column's `parse`, or * `Number` for a numeric column, and stays text otherwise; a number or boolean is used as it is. * Null when there is nothing to read, and null never matches anything but `isNull`. */ export function readRuleValue<T>(column: RuleColumn<T> | undefined, raw: RuleValue | undefined): unknown { if (raw === undefined || raw === null) return null if (typeof raw !== "string") return normalizeValue(raw) if (column?.parse) return normalizeValue(column.parse(raw)) if (column?.numeric) { const text = raw.trim().replace(/−/g, "-").replace(/,/g, "") return text === "" ? null : normalizeValue(Number(text)) } return raw } function same(a: unknown, b: unknown): boolean { if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase() if (typeof a === "number" && typeof b === "string") return a === Number(b) if (typeof a === "string" && typeof b === "number") return Number(a) === b return a === b } const needsValue = new Set<RuleOp>(["eq", "ne", "gt", "gte", "lt", "lte", "contains", "startsWith"]) /** * One condition compiled against one column: the typed values are read once, and the function that * comes back only compares. A row whose value is null answers only `isNull` and `notNull`. */ export function compileCondition<T>(condition: RuleCondition, column: RuleColumn<T>): (row: T) => boolean { const { op } = condition const want = readRuleValue(column, condition.value) const list = (condition.values ?? []).map((v) => readRuleValue(column, v)).filter((v) => v !== null) const read = (row: T) => normalizeValue(column.accessor(row)) switch (op) { case "isNull": return (row) => read(row) === null case "notNull": return (row) => read(row) !== null case "eq": return want === null ? () => false : (row) => same(read(row), want) case "ne": return want === null ? () => false : (row) => { const v = read(row) return v !== null && !same(v, want) } case "gt": case "gte": case "lt": case "lte": { if (want === null) return () => false return (row) => { const v = read(row) if (v === null) return false const c = compareValues(v, want) return op === "gt" ? c > 0 : op === "gte" ? c >= 0 : op === "lt" ? c < 0 : c <= 0 } } case "between": { const [lo, hi] = list if (lo === undefined || hi === undefined) return () => false return (row) => { const v = read(row) return v !== null && compareValues(v, lo) >= 0 && compareValues(v, hi) <= 0 } } case "in": return list.length === 0 ? () => false : (row) => list.some((item) => same(read(row), item)) case "contains": case "startsWith": { if (want === null) return () => false const needle = String(want).toLowerCase() return (row) => { const v = read(row) if (v === null) return false const text = String(v).toLowerCase() return op === "contains" ? text.includes(needle) : text.startsWith(needle) } } } } function findColumn<T>(columns: readonly RuleColumn<T>[], key: string): RuleColumn<T> | undefined { return columns.find((column) => column.key === key) } /** * Why a rule cannot apply, in a sentence, or null when it can: a column the grid does not have, a * value the column cannot read, a range without its two ends, a set with nothing in it. */ export function ruleProblem<T>(rule: { column: string; when?: RuleCondition } & Partial<RuleCondition>, columns: readonly RuleColumn<T>[]): string | null { const column = findColumn(columns, rule.column) if (!column) return `No column is named "${rule.column}".` const condition: RuleCondition = rule.when ?? { op: rule.op!, value: rule.value, values: rule.values } const name = columnName(column) const unreadable = (raw: RuleValue | undefined) => raw !== undefined && raw !== null && raw !== "" && readRuleValue(column, raw) === null if (needsValue.has(condition.op)) { if (condition.value === undefined || condition.value === null || condition.value === "") return `${RULE_OP_LABELS[condition.op]} needs a value.` if (unreadable(condition.value)) return `"${condition.value}" is not a value ${name} reads.` } if (condition.op === "between") { const values = condition.values ?? [] if (values.length !== 2) return "between needs a low value and a high value." for (const raw of values) if (unreadable(raw) || raw === "" || raw === null) return `"${raw ?? ""}" is not a value ${name} reads.` } if (condition.op === "in") { const values = (condition.values ?? []).filter((raw) => raw !== null && raw !== "") if (!values.length) return "one of needs at least one value." for (const raw of values) if (unreadable(raw)) return `"${raw}" is not a value ${name} reads.` } return null } /** The rule in words: "Price above 99-16+", "Client one of ALPHA, BETA", "Status is empty". */ export function describeRule<T>(rule: { column: string; when?: RuleCondition } & Partial<RuleCondition>, columns: readonly RuleColumn<T>[]): string { const column = findColumn(columns, rule.column) const condition: RuleCondition = rule.when ?? { op: rule.op!, value: rule.value, values: rule.values } const name = columnName(column, rule.column) const word = RULE_OP_LABELS[condition.op] const text = (raw: RuleValue | undefined) => (raw === undefined || raw === null ? "" : String(raw)) if (condition.op === "isNull" || condition.op === "notNull") return `${name} ${word}` if (condition.op === "between") { const [lo, hi] = condition.values ?? [] return `${name} ${word} ${text(lo)} and ${text(hi)}` } if (condition.op === "in") return `${name} ${word} ${(condition.values ?? []).map(text).join(", ")}` return `${name} ${word} ${text(condition.value)}` } /** Every rule has to hold. A rule on a column the grid does not have is skipped, so a stale rule hides nothing. */ export function compileFilter<T>(rules: readonly FilterRule[], columns: readonly RuleColumn<T>[]): (row: T) => boolean { const tests: ((row: T) => boolean)[] = [] for (const rule of rules) { const column = findColumn(columns, rule.column) if (column) tests.push(compileCondition(rule, column)) } if (!tests.length) return () => true if (tests.length === 1) return tests[0]! return (row) => tests.every((test) => test(row)) } /** The first rule that tells two rows apart decides, nulls last either way. Undefined when no rule names a column the grid has. */ export function compileComparator<T>(rules: readonly SortRule[], columns: readonly RuleColumn<T>[]): ((a: T, b: T) => number) | undefined { const keys: { accessor: (row: T) => unknown; dir: "asc" | "desc" }[] = [] for (const rule of rules) { const column = findColumn(columns, rule.key) if (column) keys.push({ accessor: column.accessor, dir: rule.dir }) } if (!keys.length) return undefined return (a, b) => { for (const { accessor, dir } of keys) { const c = compareDirected(accessor(a), accessor(b), dir) if (c !== 0) return c } return 0 } } /** The decoration a matched rule puts on its cell or row. */ export function ruleDecoration<T>(rule: ColumnRule, columns: readonly RuleColumn<T>[]): RuleDecoration { return { "data-rule": rule.id, "data-tone": rule.tone, "aria-description": rule.label?.trim() || describeRule(rule, columns), className: RULE_TONE_CLASS[rule.tone], } } export interface AppliedRules<T> { /** The first cell rule on this column that the row matches, or undefined. */ cell: (columnKey: string, row: T) => RuleDecoration | undefined /** The first row rule the row matches, or undefined. Shaped for the grid's `getRowProps`. */ getRowProps: (row: T) => RuleDecoration | undefined /** The rules that name a column the grid has, by column key, in the order they apply. */ byColumn: ReadonlyMap<string, readonly ColumnRule[]> } /** * Column rules compiled once: each against its column, its decoration made ahead of time, and the * grid's two hooks, `cell` and `getRowProps`, answering per row with a matched rule's decoration. A * rule naming a column the grid does not have is skipped. */ export function applyRules<T>(rules: readonly ColumnRule[], columns: readonly RuleColumn<T>[]): AppliedRules<T> { const cells = new Map<string, { test: (row: T) => boolean; decoration: RuleDecoration }[]>() const rows: { test: (row: T) => boolean; decoration: RuleDecoration }[] = [] const byColumn = new Map<string, ColumnRule[]>() for (const rule of rules) { const column = findColumn(columns, rule.column) if (!column) continue const compiled = { test: compileCondition(rule.when, column), decoration: ruleDecoration(rule, columns) } byColumn.set(rule.column, [...(byColumn.get(rule.column) ?? []), rule]) if (rule.target === "row") rows.push(compiled) else cells.set(rule.column, [...(cells.get(rule.column) ?? []), compiled]) } return { cell(columnKey, row) { const list = cells.get(columnKey) if (!list) return undefined for (const { test, decoration } of list) if (test(row)) return decoration return undefined }, getRowProps(row) { for (const { test, decoration } of rows) if (test(row)) return decoration return undefined }, byColumn, } } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --stale-soft: oklch(0.52 0.13 75 / 16%); --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); }
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.