RulesEditor

Edit one grid's highlights, filters, and sort order, with live match counts.

import { useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import type { ColumnDef } from "@/components/ui/data-grid"
import {
  RulesEditor,
  RulesEditorAdd,
  RulesEditorColumn,
  RulesEditorItem,
  RulesEditorOperator,
  RulesEditorProblem,
  RulesEditorRemove,
  RulesEditorTone,
  RulesEditorValue,
} from "@/components/ui/rules-editor"

interface Quote { px: number }
const columns: ColumnDef<Quote>[] = [
  { key: "px", header: "Price", width: 100, numeric: true, accessor: (row) => row.px },
]

export default function RulesEditorDemo() {
  const [rules, setRules] = useState<ReadGridRules>({
    columns: [{ id: "price", column: "px", when: { op: "gte", value: "100" }, tone: "up" }],
  })
  return (
    <RulesEditor columns={columns} rules={rules} onRulesChange={setRules} className="w-lg max-w-full">
      {rules.columns?.length ? (
        <ul className="space-y-2">
          {rules.columns?.map((rule, index) => (
            <li key={rule.id ?? index}>
              <RulesEditorItem kind="highlights" index={index}>
                <RulesEditorColumn />
                <RulesEditorOperator />
                <RulesEditorValue />
                <RulesEditorValue field="low" />
                <RulesEditorValue field="high" />
                <RulesEditorValue field="values" />
                <RulesEditorTone />
                <RulesEditorRemove>Remove</RulesEditorRemove>
                <RulesEditorProblem />
              </RulesEditorItem>
            </li>
          ))}
        </ul>
      ) : (
        <p className="text-muted-foreground">No highlights.</p>
      )}
      <RulesEditorAdd kind="highlights">Add highlight</RulesEditorAdd>
    </RulesEditor>
  )
}

Installation

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

Usage

import { useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import type { ColumnDef } from "@/components/ui/data-grid"
import {
  RulesEditor,
  RulesEditorAdd,
  RulesEditorColumn,
  RulesEditorItem,
  RulesEditorOperator,
  RulesEditorProblem,
  RulesEditorRemove,
  RulesEditorTone,
  RulesEditorValue,
} from "@/components/ui/rules-editor"

interface Quote { px: number }
const columns: ColumnDef<Quote>[] = [
  { key: "px", header: "Price", width: 100, numeric: true, accessor: (row) => row.px },
]

function QuoteRules() {
  const [rules, setRules] = useState<ReadGridRules>({
    columns: [{ id: "price", column: "px", when: { op: "gte", value: "100" }, tone: "up" }],
  })
  return (
    <RulesEditor columns={columns} rules={rules} onRulesChange={setRules} className="w-lg max-w-full">
      {rules.columns?.length ? (
        <ul className="space-y-2">
          {rules.columns?.map((rule, index) => (
            <li key={rule.id ?? index}>
              <RulesEditorItem kind="highlights" index={index}>
                <RulesEditorColumn />
                <RulesEditorOperator />
                <RulesEditorValue />
                <RulesEditorValue field="low" />
                <RulesEditorValue field="high" />
                <RulesEditorValue field="values" />
                <RulesEditorTone />
                <RulesEditorRemove>Remove</RulesEditorRemove>
                <RulesEditorProblem />
              </RulesEditorItem>
            </li>
          ))}
        </ul>
      ) : (
        <p className="text-muted-foreground">No highlights.</p>
      )}
      <RulesEditorAdd kind="highlights">Add highlight</RulesEditorAdd>
    </RulesEditor>
  )
}

Composition

Use the following composition to build a RulesEditor:

RulesEditor
└── Tabs (optional)
    ├── TabsList (optional)
    │   └── TabsTrigger
    │       └── RulesEditorRuleCount (optional)
    ├── TabsContent (optional)
    │   ├── Your list and empty state
    │   │   └── RulesEditorItem
    │   │       ├── RulesEditorColumn
    │   │       ├── RulesEditorOperator (highlights and filters)
    │   │       ├── RulesEditorValue
    │   │       ├── RulesEditorValue field="low"
    │   │       ├── RulesEditorValue field="high"
    │   │       ├── RulesEditorValue field="values"
    │   │       ├── RulesEditorTone (highlights)
    │   │       ├── RulesEditorToneSwatch (optional)
    │   │       ├── RulesEditorTarget (highlights)
    │   │       ├── RulesEditorLabel (highlights)
    │   │       ├── RulesEditorDirection (sort)
    │   │       ├── RulesEditorMatchCount (optional)
    │   │       ├── RulesEditorProblem
    │   │       ├── RulesEditorMove direction="up" (optional)
    │   │       ├── RulesEditorMove direction="down" (optional)
    │   │       └── RulesEditorRemove
    │   ├── RulesEditorAdd
    │   └── RulesEditorFilterCount (optional)
    └── TabsContent value="columns" (optional)
        └── ColumnChooser
            └── Your column settings composition

Place items directly inside your layout when you do not need tabs.

Tabs

Compose installed shadcn Tabs to group rules with a column chooser. Select each tab on focus so arrow keys switch panels on either base. Copy ColumnSettingsPanel and its helpers from the ColumnChooser Usage example into column-chooser.tsx beside this example, outside components/ui.

Control Tabs with value and onValueChange to drive panels from your own controls.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useState } from "react"
import { NUMERIC_CLASS, createInstrumentFormatter, formatNotional } from "@/lib/format"
import type { ReadGridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import { ColumnSettingsPanel } from "./column-chooser"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"
import { RulesEditor, RulesEditorAdd, RulesEditorColumn, RulesEditorDirection, RulesEditorFilterCount, RulesEditorItem, RulesEditorLabel, RulesEditorMatchCount, RulesEditorMove, RulesEditorOperator, RulesEditorProblem, RulesEditorRemove, RulesEditorRuleCount, RulesEditorTarget, RulesEditorTone, RulesEditorToneSwatch, RulesEditorValue, useRulesEditor, type RulesEditorProps } from "@/components/ui/rules-editor"

interface Rfq {
  id: string
  client: string
  size: number
  px: number | null
}

const ust = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })

const columns: ColumnDef<Rfq>[] = [
  { key: "client", header: "Client", width: 180, sortable: true, accessor: (r) => r.client },
  { key: "size", header: "Size", width: 160, numeric: true, sortable: true, accessor: (r) => r.size, format: (v) => formatNotional(v as number, { unit: "mm" }) },
  { key: "px", header: "Price", width: 170, numeric: true, font: "mono", sortable: true, accessor: (r) => r.px, format: (v) => ust.price(v as number | null), parse: ust.parsePrice },
]

const rows: Rfq[] = [
  { id: "Q-1", client: "ALPHA", size: 5_000_000, px: 99.5 },
  { id: "Q-2", client: "BETA", size: 25_000_000, px: 100.015625 },
  { id: "Q-3", client: "GAMMA", size: 10_000_000, px: null },
]

// The editor and the grid share one rules object and one column state. Edit a rule and the grid follows the keystroke.
export default function RulesEditorTabsDemo() {
  const [store] = useState(() => {
    const s = createRowStore<Rfq>({ getRowId: (r) => r.id })
    s.applyDeltas({ upsert: rows })
    return s
  })
  const [rules, setRules] = useState<ReadGridRules>({
    columns: [{ id: "threshold", column: "px", when: { op: "gte", value: "100-00" }, tone: "primary", label: "Price threshold" }],
    filter: [],
    sort: [],
  })
  const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: {}, hidden: [] })
  return (
    <div className="w-lg max-w-full space-y-3">
      <TabbedRulesEditor className="overflow-x-auto" columns={columns} rules={rules} onRulesChange={setRules} store={store} columnState={columnState} onColumnStateChange={setColumnState} />
      <div className="h-48">
        <DataGrid store={store} columns={columns} preset="watchlist" label="Quotes with rules" rules={rules} columnState={columnState} onColumnStateChange={setColumnState} />
      </div>
    </div>
  )
}

type TabValue = "highlights" | "filters" | "sort" | "columns"

export function TabbedRulesEditor<T>({ columnState, onColumnStateChange, defaultTab = "highlights", ...props }: Omit<RulesEditorProps<T>, "children"> & { defaultTab?: TabValue; columnState?: ColumnState; onColumnStateChange?: (state: ColumnState) => void }) {
  return (
    <RulesEditor {...props}>
      <RulesEditorSections defaultTab={defaultTab} columns={props.columns} columnState={columnState} onColumnStateChange={onColumnStateChange} />
    </RulesEditor>
  )
}

export function RulesEditorSections<T>({ columns, columnState, onColumnStateChange, defaultTab = "highlights" }: { columns: ColumnDef<T>[]; defaultTab?: TabValue; columnState?: ColumnState; onColumnStateChange?: (state: ColumnState) => void }) {
  const { rules, labels } = useRulesEditor()
  const [tab, setTab] = useState<string>(defaultTab)
  const hasColumns = columnState !== undefined && onColumnStateChange !== undefined
  const selected = tab === "columns" && !hasColumns ? "highlights" : tab
  return (
    <Tabs value={selected} onValueChange={(value) => setTab(String(value))}>
      <TabsList aria-label={labels.title} className="h-auto max-w-full flex-wrap">
        <TabsTrigger value="highlights" onFocus={() => setTab("highlights")}>{labels.highlights}<RulesEditorRuleCount kind="highlights" /></TabsTrigger>
        <TabsTrigger value="filters" onFocus={() => setTab("filters")}>{labels.filters}<RulesEditorRuleCount kind="filters" /></TabsTrigger>
        <TabsTrigger value="sort" onFocus={() => setTab("sort")}>{labels.sort}<RulesEditorRuleCount kind="sort" /></TabsTrigger>
        {hasColumns && <TabsTrigger value="columns" onFocus={() => setTab("columns")}>{labels.columns}{columnState.hidden.length > 0 && <span className={`${NUMERIC_CLASS} text-muted-foreground`}>{columnState.hidden.length}</span>}</TabsTrigger>}
      </TabsList>
      <TabsContent value="highlights">
        <div className="flex flex-col gap-2">
          {rules.columns?.length ? (
            <ul className="flex flex-col gap-1">
              {rules.columns?.map((rule, index) => (
                <li key={rule.id ?? index}>
                  <RulesEditorItem kind="highlights" index={index}>
                    <span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
                    <RulesEditorColumn />
                    <RulesEditorOperator />
                    <RulesEditorValue />
                    <RulesEditorValue field="low" />
                    <RulesEditorValue field="high" />
                    <RulesEditorValue field="values" />
                    <RulesEditorTone />
                    <RulesEditorToneSwatch />
                    <RulesEditorTarget />
                    <RulesEditorLabel />
                    <RulesEditorMatchCount />
                    <RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
                    <RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
                    <RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
                    <RulesEditorProblem />
                  </RulesEditorItem>
                </li>
              ))}
            </ul>
          ) : (
            <p className="text-muted-foreground">{labels.noHighlights}</p>
          )}
          <RulesEditorAdd kind="highlights">{labels.addHighlight}</RulesEditorAdd>
          <p className="text-muted-foreground">{labels.dragHint}</p>
        </div>
      </TabsContent>
      <TabsContent value="filters">
        <div className="flex flex-col gap-2">
          {rules.filter?.length ? (
            <ul className="flex flex-col gap-1">
              {rules.filter?.map((_, index) => (
                <li key={index}>
                  <RulesEditorItem kind="filters" index={index}>
                    <span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
                    <RulesEditorColumn />
                    <RulesEditorOperator />
                    <RulesEditorValue />
                    <RulesEditorValue field="low" />
                    <RulesEditorValue field="high" />
                    <RulesEditorValue field="values" />
                    <RulesEditorMatchCount />
                    <RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
                    <RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
                    <RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
                    <RulesEditorProblem />
                  </RulesEditorItem>
                </li>
              ))}
            </ul>
          ) : (
            <p className="text-muted-foreground">{labels.noFilters}</p>
          )}
          <div className="flex flex-wrap items-center gap-2">
            <RulesEditorAdd kind="filters">{labels.addFilter}</RulesEditorAdd>
            <RulesEditorFilterCount />
          </div>
          <p className="text-muted-foreground">{labels.dragHint}</p>
        </div>
      </TabsContent>
      <TabsContent value="sort">
        <div className="flex flex-col gap-2">
          {rules.sort?.length ? (
            <ol className="flex flex-col gap-1">
              {rules.sort?.map((_, index) => (
                <li key={index}>
                  <RulesEditorItem kind="sort" index={index}>
                    <span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
                    <span aria-hidden className={`${NUMERIC_CLASS} w-4 text-right text-muted-foreground`}>{index + 1}</span>
                    <RulesEditorColumn />
                    <RulesEditorDirection />
                    <RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
                    <RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
                    <RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
                    <RulesEditorProblem />
                  </RulesEditorItem>
                </li>
              ))}
            </ol>
          ) : (
            <p className="text-muted-foreground">{labels.noSort}</p>
          )}
          <RulesEditorAdd kind="sort">{labels.addSort}</RulesEditorAdd>
          <p className="text-muted-foreground">{labels.dragHint}</p>
        </div>
      </TabsContent>
      {hasColumns && <TabsContent value="columns"><div className="flex flex-col gap-2"><ColumnSettingsPanel columns={columns} columnState={columnState} onColumnStateChange={onColumnStateChange} rules={rules.columns} /></div></TabsContent>}
    </Tabs>
  )
}

Settings layout

Move fields and actions into cards with your own headings and content.

import { useId, useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import type { ColumnDef } from "@/components/ui/data-grid"
import { RulesEditor, RulesEditorAdd, RulesEditorColumn, RulesEditorDirection, RulesEditorFilterCount, RulesEditorItem, RulesEditorMatchCount, RulesEditorMove, RulesEditorOperator, RulesEditorProblem, RulesEditorRemove, RulesEditorValue } from "@/components/ui/rules-editor"

interface Order { id: string; account: string; size: number }
const columns: ColumnDef<Order>[] = [
  { key: "account", header: "Account", width: 120, accessor: (row) => row.account },
  { key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size },
]

export default function RulesEditorLayoutDemo() {
  const id = useId()
  const [store] = useState(() => {
    const store = createRowStore<Order>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [{ id: "a", account: "ALPHA", size: 500 }, { id: "b", account: "BETA", size: 200 }] })
    return store
  })
  const [rules, setRules] = useState<ReadGridRules>({
    filter: [{ column: "size", op: "gte", value: "300" }],
    sort: [{ key: "size", dir: "desc" }, { key: "account", dir: "asc" }],
  })
  return (
    <RulesEditor columns={columns} rules={rules} onRulesChange={setRules} store={store} labels={{ moveUp: "Earlier", moveDown: "Later" }} aria-labelledby={`${id}-title`} className="w-2xl max-w-full gap-4">
      <header className="flex flex-wrap items-center justify-between gap-2">
        <h2 id={`${id}-title`} className="text-sm font-semibold">Order priority</h2>
        <RulesEditorFilterCount />
      </header>
      <div className="grid gap-4 sm:grid-cols-2">
        <section className="min-w-0 space-y-2" aria-labelledby={`${id}-include`}>
          <h3 id={`${id}-include`} className="font-medium">Include orders</h3>
          <p className="text-muted-foreground">Conditions apply to all accounts.</p>
          {!rules.filter?.length && <p>Every order is included.</p>}
          {rules.filter?.map((_, index) => (
            <RulesEditorItem kind="filters" index={index} key={index} className="flex-col items-stretch gap-2 p-3">
              <header className="flex flex-wrap items-center justify-between gap-1">
                <RulesEditorColumn />
                <RulesEditorRemove>Remove</RulesEditorRemove>
              </header>
              <div className="flex flex-wrap items-center gap-2">
                <RulesEditorOperator />
                <RulesEditorValue />
                <RulesEditorValue field="low" />
                <RulesEditorValue field="high" />
                <RulesEditorValue field="values" />
              </div>
              <RulesEditorProblem />
              <footer className="flex flex-wrap items-center gap-1">
                <RulesEditorMatchCount />
                <RulesEditorMove direction="up">Earlier</RulesEditorMove>
                <RulesEditorMove direction="down">Later</RulesEditorMove>
              </footer>
            </RulesEditorItem>
          ))}
          <RulesEditorAdd kind="filters">Add condition</RulesEditorAdd>
        </section>
        <section className="min-w-0 space-y-2" aria-labelledby={`${id}-priority`}>
          <h3 id={`${id}-priority`} className="font-medium">Priority</h3>
          <p className="text-muted-foreground">Earlier keys take precedence.</p>
          {!rules.sort?.length && <p>Keep arrival order.</p>}
          {rules.sort?.map((_, index) => (
            <RulesEditorItem kind="sort" index={index} key={index} className="flex-col items-stretch gap-2 p-3">
              <div className="flex flex-wrap items-center gap-2">
                <RulesEditorDirection />
                <RulesEditorColumn />
              </div>
              <RulesEditorProblem />
              <footer className="flex flex-wrap items-center gap-1">
                <RulesEditorMove direction="up">Earlier</RulesEditorMove>
                <RulesEditorMove direction="down">Later</RulesEditorMove>
                <RulesEditorRemove className="ml-auto">Remove</RulesEditorRemove>
              </footer>
            </RulesEditorItem>
          ))}
          <RulesEditorAdd kind="sort">Add priority</RulesEditorAdd>
        </section>
      </div>
      <p className="text-muted-foreground">Drag a card or use Alt+Up/Down to reorder.</p>
    </RulesEditor>
  )
}

API Reference

Props

RulesEditorProps<T> extends native div props and requires children.

Prop Type Default Purpose
columns ColumnDef<T>[] Required Column definitions shared with the grid.
rules ReadGridRules Required Controlled rules, written or read with readRules. Omitted lists are empty.
onRulesChange (rules: ReadGridRules) => void Required Receives each edit. An edit can leave a rule incomplete, as typing does, so hold the editor's rules as ReadGridRules, the type the grid takes.
children ReactNode Required Your sections, items, controls, and empty states.
store RowStore<T> - Rows for match counts.
labels Partial<RulesEditorLabels> DEFAULT_RULES_EDITOR_LABELS Field, action, count, and region labels. A word given as undefined keeps its default.
className string - Additional classes on the root.

RulesEditorKind is "highlights" | "filters" | "sort".

Each item indexes its source list: "highlights" uses rules.columns, "filters" uses rules.filter, and "sort" uses rules.sort.

Parts

Parts forward refs, classes, and native attributes to their element or the corresponding shadcn control.

Part Element Description
RulesEditorItem div Requires kind, source index, and children. Coordinates fields and reordering.
RulesEditorColumn NativeSelect Column choices from the root.
RulesEditorOperator NativeSelect Operators supported by the current column.
RulesEditorValue Input Optional field: "value" | "low" | "high" | "values", default "value". Renders only for its operator shape.
RulesEditorTone NativeSelect Highlight tone.
RulesEditorToneSwatch span Decorative tone name in foreground text on the tone's tint.
RulesEditorTarget NativeSelect Highlight target, "cell" or "row".
RulesEditorLabel Input Highlight description.
RulesEditorDirection NativeSelect Sort direction, "asc" or "desc".
RulesEditorProblem p Validation message when the item has a problem.
RulesEditorMatchCount span Independent highlight or filter matches. Requires store.
RulesEditorFilterCount span Combined filter matches and total rows. Requires store.
RulesEditorRuleCount span Requires kind. Hides zero counts.
RulesEditorAdd Button Requires kind and children. Appends a rule with the helper defaults.
RulesEditorMove Button Requires direction: "up" | "down" and children. Disabled at the corresponding end.
RulesEditorRemove Button Requires children. Removes the current item.

Field values and options belong to the editor. Use useRulesEditorItem to replace a control, or cancel its event with preventDefault().

Select classes apply to the shadcn wrapper. Input classes apply to the input.

Item fields, move/remove actions, problems, and match counts require RulesEditorItem. Fields for another rule kind render nothing.

Keyboard and focus

Drag an item onto another item of the same kind, or use Alt+Up/Down from the item or its buttons. A select, a text field, or an item given an arrow-owning role keeps Alt with its arrows, so Alt+Down opens a select, and Alt with Shift, Ctrl, or Meta moves nothing. Each item names its keys in aria-keyshortcuts, unless its role or contentEditable keeps the arrows. Reordering uses source indices, even when you render items in a different order.

Accepted moves keep focus on the corresponding field when available. Removal focuses the adjacent item or its remove action, then RulesEditorAdd when the list is empty.

useRulesEditor

Use useRulesEditor() inside the root for custom lists and controls.

Field Type Description
rules ReadGridRules Current controlled rules.
labels RulesEditorLabels Merged labels.
columns { key: string; name: string; ops: readonly RuleOp[] }[] Column choices and supported operators.
problem (rule: ReadColumnRule | ReadFilterRule, kind: RuleKind) => string | null Validation for the current columns, judged as the kind of rule it is.
change (next: Partial<ReadGridRules>) => void Merges changed lists into the controlled rules.
add (kind: RulesEditorKind) => void Appends the corresponding new rule.
move (kind: RulesEditorKind, from: number, to: number) => void Moves a rule by source index.
remove (kind: RulesEditorKind, index: number) => void Removes a rule by source index.

useRulesEditorItem

Use useRulesEditorItem() inside an item to build a custom field.

Field Type Description
kind RulesEditorKind Item kind.
index number Index in the source list.
name string Highlight label or kind name and position.
columnKey string Selected column, or "" for a rule without one.
condition ReadCondition | null Highlight or filter condition, as read.
highlight ReadColumnRule | null Current highlight, as read, if applicable.
filter ReadFilterRule | null Current filter, as read, if applicable.
sort ReadSortRule | null Current sort key, as read, if applicable.
problem string | null Current validation message.
setColumn (key: string) => void Changes the column and resets unsupported operators.
setCondition (condition: ReadCondition) => void Changes a highlight or filter condition.
updateHighlight (patch: Partial<ReadColumnRule>) => void Updates highlight properties.
setDirection (direction: "asc" | "desc") => void Changes sort direction.

It produces rules and keeps nothing

Accept each onRulesChange result into rules and share it with the grid. Keep a separate draft for a save step.

Edits create a new object and edited list. Untouched lists and rules retain their references, and an edited rule keeps every field your app saved on it and on its condition.

Replace changed arrays and objects. The editor retains transient drag and comma-field state, including unfinished text such as ALPHA,.

A rule as it is typed

RulesEditorOperator offers comparisons and ranges for numeric columns, text matching otherwise, and equality, sets, and null checks for both.

Operators RulesEditorValue fields
eq, ne, gt, gte, lt, lte, contains, startsWith "value"
between "low" and "high", inclusive
in "values", comma separated
isNull, notNull None

Render all four value fields: <RulesEditorValue />, <RulesEditorValue field="low" />, <RulesEditorValue field="high" />, and <RulesEditorValue field="values" />. Each renders only when the operator needs it.

withOp keeps values for the same shape, or when the old op is one no version knows, and clears them otherwise. withColumn keeps supported conditions, or selects the first operator and drops values. Both keep any other field the condition carries.

Values stay as typed strings in the model and the fields, read through the column's parse function or numerically for numeric columns. A 32nds parser accepts 100-00, and a typed decimal compares on the convention's printable grid; a column format makes descriptions print the compared price.

The set field trims members and drops empties, without quoting or escaping. Enter 1000000 for one numeric member.

Errors

RulesEditorProblem reports missing columns, a missing or unknown comparison, missing values, unreadable values, a range whose low end is above its high end, and a missing or unknown tone, without blocking edits or evaluation. A stored op the column doesn't offer shows in its select under its word, an op or a tone this version doesn't know shows quoted, and a column key no column has shows as saved. A missing column, op, or tone shows blank, so a highlight with no condition gets a blank one to choose from, and a missing column stays blank beside a column keyed "", which the grid doesn't use for it. The editor reads each entry as the grid does, so an entry that isn't an object renders and counts nothing, and a name or a word saved as anything but text shows as its JSON. Read rules a desk saved or shared with readRules before you hold them, so your own composition maps over rule objects.

Condition Evaluation
Unreadable single numeric value Matches nothing.
Mixed valid/invalid in set Valid members can match.
Empty text Can match despite a missing-value message.
Missing column Individual count is zero. Combined filtering and sorting skip it.
Missing or unknown comparison Matches nothing.

Highlights

Use RulesEditorTone, RulesEditorTarget, and RulesEditorLabel for highlight properties.

Property Values or behavior
Tone up, down, flat, stale, expiring, primary, destructive.
Target "cell" by default, or "row".
Label Grid description and chooser badge. Blank labels use describeRule.
Precedence First matching cell rule per column, and first matching row rule.

Filters and the count

Use RulesEditorMatchCount for individual matches and RulesEditorFilterCount for the combined total.

Every filter on a known column must match.

Counts read all store rows, including hidden rows and highlights that lose precedence. Separate grid filters and custom views do not affect them.

Counts recompute on mount and changes to their rule lists, columns, or store. Store notifications update counts at most once per 250 ms, while edits update immediately.

Repeated count parts share one subscription. Feed updates rerender readings without rerendering editable fields, and pending updates are cancelled on store replacement or unmount.

Sort

Use RulesEditorDirection to set each key's direction. The first key that distinguishes two rows decides their order.

Duplicate keys are allowed. Grid rules break header-sort ties, while a caller-supplied view owns filtering and sorting.

Helpers

Import these helpers from @/components/ui/rules-editor. Rule types, opsFor, ruleProblem, and describeRule come from grid-rules.

Here columns is readonly ColumnDef<T>[], condition is ReadCondition, and op is RuleOp.

Helper Return type Behavior
valueShape(op) "one" | "two" | "many" | "none" Shape in the operator table.
parseValues(text: string) RuleValue[] Comma-separated, trimmed, nonempty strings.
valuesText(values: readonly RuleValue[] | undefined) string Joins with ", ". null becomes empty text, and undefined gives "".
withOp(condition, op) ReadCondition & { op: RuleOp } Copies for the same shape or an unknown old op. Otherwise drops value and values and keeps the other fields.
withColumn(condition, column: ColumnDef<T> | undefined) ReadCondition & { op: RuleOp } Same condition if supported. Otherwise the first operator, without values, keeping the other fields.
moveItem<X>(list: readonly X[], from: number, to: number) X[] Moves by index. Equal or out-of-bounds indices return an unchanged copy.
newRuleId() string Timestamp plus module-local counter.
newHighlight(columns) ColumnRule First column/operator, fresh id, tone "up".
newFilter(columns) FilterRule First column/operator.
newSort(columns, existing: readonly ReadSortRule[] = []) SortRule First unused column, or first column if exhausted. Uses dir: "asc".

With no columns, add buttons stay enabled. Helpers use an empty key, with eq for highlights and filters.

Labels

Use labels to override field names, action names, count templates, and the region title.

Move and remove actions use <label>: <rule>. Match their visible text to labels.moveUp, labels.moveDown, and labels.remove.

Fields use <field>: <rule>, such as Value: Rich to the market. Highlights use their nonblank label, while unnamed items use kind and position.

Use useRulesEditor().labels for your tab text, action children, empty states, and drag hint. labels.ruleWords writes the comparison and tone choices and every problem in your words, as grid-rules' RuleWords, and labels.itemName names a rule with no label, {kind} its section's word and {n} its place. A rule with a problem marks its item invalid, and the item and its value fields point to RulesEditorProblem's line. A count's label is a template or a function, for a language whose plurals a template can't hold: matches takes {n}, the rows that match, or (n) => string, and shown takes {n}, the rows shown, and {m}, all the rows, or (n, m) => string. The defaults put one in the singular, as in 1 row matches.

Operator words, tone names, and problems default to grid-rules' DEFAULT_RULE_WORDS, which labels.ruleWords overrides; ColumnChooser takes its own labels.ruleWords.

What it does not do

The editor emits rules and evaluates counts. The grid applies them, and the caller owns persistence, list markup, empty states, and surrounding content.

Tokens

Installation adds missing up, down, and flat tokens, and the soft variants of those and of stale and expiring. primary and destructive use the host theme.

Built on shadcn's badge, button, checkbox, context-menu, dialog, dropdown-menu, input, native-select, and tabs.

This page is docs/rules-editor.md at v2.0.0.