patterns
RuleBuilder
Schema-driven multi-condition / multi-group filter constructor — the visual answer to MailChimp segments, Linear filters, Notion database filters. UI + local state only; you bring the schema, persist the value, and decide how to evaluate it against your data.
The component is fully controlled — `value` is a JSON document you can serialize. Every field on a row (subject, operator, value, scope, unit) renders as a same-height pill so the row reads as one tightly-packed inline-flex; groups visually nest via a 2px left border, never as filled cards. Drag handle and delete appear on row-hover only.
Install
Import from the workspace package:
import { RuleBuilder, RuleSchema, RuleFieldDef, RuleFieldType, RuleValue, RuleNode, RuleOperator, RuleQuantifier, RuleBuilderLabels } from "@8maverik8/design";Examples
Cip-style audience builder
Live, fully interactive. Click any pill to change it, click + Add condition / Add group, change the All-of quantifier, hover any row for the drag handle and delete affordance.
import { RuleBuilder, type RuleSchema, type RuleValue } from "@8maverik8/design";
const schema: RuleSchema = {
fields: [
{ id: "is_delinquent", label: "Delinquency", type: { kind: "boolean" } },
{ id: "has_open_ticket", label: "Open ticket", type: { kind: "boolean" } },
{ id: "bad_csat_count", label: "Bad CSAT",
type: { kind: "number-window", label: "no more than",
windowUnits: ["days","weeks","months"],
defaultWindow: 180, defaultWindowUnit: "days" } },
{ id: "events_30d", label: "Events (30d)",
type: { kind: "number-scope",
scopes: [{ id: "any-app", label: "any app" },
{ id: "specific-app", label: "specific app" }],
unit: "events" } },
{ id: "tenure_days", label: "Tenure",
type: { kind: "number", unit: "days" } },
{ id: "lifecycle_stage", label: "Lifecycle stage",
type: { kind: "enum", options: [
{ id: "trial", label: "Trial" },
{ id: "active", label: "Active" },
{ id: "churn-risk", label: "Churn risk" },
{ id: "dormant", label: "Dormant" },
] } },
],
};
const [value, setValue] = useState<RuleValue>(initialValue);
<RuleBuilder schema={schema} value={value} onChange={setValue} />
// Persist value as JSON. Reload from JSON. That's the whole contract.Localized (Russian) chrome
The same builder with the optional `labels` prop. Field and option labels come from the schema; `labels` translates the component's own chrome — operators, quantifier pills + captions, empty state, add buttons, the rule counter, and command-palette placeholders. Glyph operators (≥ ≤ = ≠ > <) stay as-is.
import {
RuleBuilder,
type RuleBuilderLabels,
type RuleSchema,
type RuleValue,
} from "@8maverik8/design";
const ruLabels: Partial<RuleBuilderLabels> = {
operatorIs: "равно",
operatorIsNot: "не равно",
operatorContains: "содержит",
operatorStartsWith: "начинается с",
quantifierAll: "Все из",
quantifierAny: "Любое из",
quantifierNone: "Ни одно из",
quantifierHintAll: "должны выполняться все условия ниже",
quantifierHintAny: "должно выполняться хотя бы одно условие ниже",
quantifierHintNone: "не должно выполняться ни одно условие ниже",
emptyState: "Пока нет условий. Нажмите {action}, чтобы начать.",
emptyStateAction: "+ Добавить условие",
addCondition: "Добавить условие",
addGroup: "Добавить группу",
ruleCountSingular: "правило",
ruleCountPlural: "правил",
pickFieldTrigger: "Выберите поле",
findFieldPlaceholder: "Поиск поля…",
noFieldFound: "Поле не найдено.",
pickValuePlaceholder: "Выбрать…",
};
<RuleBuilder
schema={schema}
value={value}
onChange={setValue}
title="Сравнение"
labels={ruLabels}
/>
// Defaults are English — omit `labels` and nothing changes.Variants
RuleFieldType.kind
boolean`is true` / `is not true`. No value editor.numberNumeric ≥/≤/=/≠/>/<. Optional unit suffix ("days", "events").number-window"{label} N {unit}" — e.g. "no more than 0 occurrences within 180 days". Use for time-windowed thresholds.number-scope"≥ N {unit} for {scope}" — e.g. "≥ 100 events for any app". Use when the count is measured against a sub-entity.stringFree-text value with `is / is not / contains / starts with`.enumDropdown of named options. `is / is not`.
Anatomy
schemaDescribes the fields the user can filter by + their value types. Owned by the host product (Cip / Archi / future) — stays close to your data model.valueControlled JSON — `{ quantifier, rules: RuleNode[] }`. Persist it as-is, load it back on mount.onChangeFires on every edit with the next value. Caller decides debounce / save / autosave.rootQuantifierOverride the top-level All / Any / None for the very first quantifier pill. Defaults to `value.quantifier`.maxNestingDepthHow deep groups can nest. Default 2 (one level of nested groups). Set to 1 for a flat list.labelsOptional `Partial<RuleBuilderLabels>` to localize every chrome string — word-operators, quantifier pills + captions, empty state, add buttons, the rule counter, and command-palette placeholders. Shallow-merged over English defaults, so override only what you need. Glyph operators (≥ ≤ = ≠ > <) stay language-neutral. Field/option labels come from the schema.
Guidelines
- Define the schema close to your data model and pass it in. The component is dumb about your fields — that's the point; one component, every product, every domain.
- Treat the emitted `value` as the contract you persist and parse server-side. Same JSON shape feeds your SQL/Drizzle WHERE later.
- Render two different RuleBuilders on one page sharing state.Each constructor is one query. If you need a saved-segment selector inside a builder, that's a custom value renderer for a `field-as-saved-segment` type — not a parallel builder.
- Bolt extra UI inside `RuleRow` via DOM hacks.If you need a new field type (e.g. "saved segment reference", "date range"), it's a new entry in the field-type union, not a row override.
- Style your container to constrain max-width — a typical /audiences page caps it at ~768-960px so long pill rows wrap cleanly.