ConditionEditor

GithubStorybook

The ConditionEditor components lay out editable condition rows — a card per condition holding the controls that edit it, joined to the rows around it by their operator.

Importing

import { ConditionEditor, ConditionEditorItem } from '@customerio/pluma-components/react';

Usage

Stack ConditionEditorItems in a ConditionEditor. An item's children are its first line of controls: pass them directly, without a stack of your own around them. Give every row after the first an operator.

Loading editor

operator is plain text, shown as a Badge between the row and the one before it. Write it in sentence case ("And", "Or") and leave it unset on the first row.

Actions and details

actions holds what acts on the whole condition — duplicate, remove — at the trailing edge of the card. details holds what goes below the first line: a refinement, filter rows, an "Add filter" button. Lay out the content of details yourself.

Loading editor

Heading

heading puts a caption above the controls, inside the card: what kind of condition the row is ("Object attribute"), with an icon in a small Coin if you like. The handle, the connector and the actions line up with the heading instead of the controls. The heading also names the row's controls for screen readers.

When one field picker covers several kinds of field, derive the heading from the chosen field, so picking a field of another kind relabels the row instead of needing a separate type control.

Loading editor

A set takes a header above its rows, headerActions at the header's trailing edge, and a footer below its rows. Use them for a group: the header says how its conditions combine, with the control that changes it; the header's actions duplicate or remove the group; the footer adds to it. The header spans the group's full width, out past the cards and the rows' actions, so it reads as governing the rows; the footer lines up with the cards. A set with a header is announced as a group named by it.

When the header sets how the group matches, keep the operator labels between its rows read-only so there's one place to change it.

Loading editor

footer is an open slot at the foot of the card, below the controls and the details. Put whatever the row needs there (an example of what it matches, a link, a note), composed from Pluma components; its pieces sit in a row that starts where the controls start. Pluma doesn't style what's inside, so pick the text style yourself.

Loading editor

Errors and alignment

Every form control and Button in a row lines up on the same center line, and an error appears below its own control without moving the rest of the row. Don't pass withCenterBaseline to them yourself. Wrap other inline content, like an icon, in CenterBaseline to line it up with the controls.

Reordering

withHandle renders a "Reorder condition" button beside the card, and isHandleDisabled disables it. Only the handle starts a drag, never the card, so the controls inside a row stay usable. The handle doesn't drag on its own: give its element to your drag-and-drop library with handleRef. In React, pass any ref — dnd-kit's handleRef works as is. In Ember, pass a callback; it's called with the button when it renders and with null when it's removed.

Pluma doesn't ship or require a drag-and-drop library. It styles the row in flight — a ring and a fade on its card — for as long as the library marks the handle aria-pressed or aria-grabbed, as dnd-kit does for both pointer and keyboard drags. While a row is dragged, mark the row it would land next to with dropIndicator to draw the line where it will go. The examples below use dnd-kit with its live preview turned off, so the rows hold still and the line shows where the dragged row will land. Drag a row by its handle.

Loading editor

Your drag-and-drop library is also responsible for keyboard reordering and for announcing moves. See Accessibility.

Drop indicator

dropIndicator draws the line that shows where a dragged row will land: 'before' the row or 'after' it. Your application decides which row and which side, and whether a move is allowed; Pluma only draws the line. A slot between two rows draws the same line whichever side marks it, so the line doesn't jump as the pointer crosses from one row to the next. Between rows joined by an operator, it runs through the operator strip, level with the operator, starting just after it. The line is trimmed short of the card on both sides and starts at the card's own edge (after the operator, where there is one), so in a nested set its indent shows which group the row lands in. It takes no space, so the rows don't move when it appears.

The prop sets data-drop-indicator on the row. A drag layer can set that attribute directly instead, to move the line without re-rendering the rows.

Loading editor

In a virtualized list, where each row sits in its own wrapper, mark the row below a slot ('before') rather than the row above it: a row can't see past its own wrapper to tell what follows it.

Changing the operator

Pass operatorOptions and onOperatorChange to make a row's operator a control. The label becomes a small SegmentedControl of the options: an operator is a choice of two, and two options don't need a menu to hide one of them, so both show and changing it is one click. Picking one calls onOperatorChange with its value; operator is the chosen option's value. Use a SegmentedControl for a group's All / Any match in its header too, so the two read the same. Without both props, the operator stays the read-only label, so each row can be switchable or not, and only a switchable operator renders as a control. On a connector, an operator (the control or the read-only label) sits in line with the cards, the way a row does, with a branch from the connector like the ones into the cards, so the cards keep the narrow gutter. Each row's operator is its own: Pluma draws the operators it's given and reports which row changed, and your application decides what the change means.

Rows in one set read as one expression, so a set that mixes operators is ambiguous: And, then Or, across three rows could mean (plan && company) || email or plan && (company || email). To show which, nest a ConditionEditor for the part that binds tighter.

Any row's operator can be a control, and any number of rows in a set can have one; each changes its own row alone. A control between two rows reads as joining those two, so changing it shouldn't change any other row:

  • When a set has one operator for all its rows, put the control in the set's header (an All / Any SegmentedControl, as under Header and footer) and show the operators between rows read-only. The header visibly belongs to the whole set.
  • Don't make one row's operator switchable and have the rows after it follow. Toggling it would change labels elsewhere with nothing saying why.
  • Independent controls on several rows are fine where your application accepts mixed operators in a set, or nests the part that binds tighter as above.

The example below has a two-row set at each level, so each set's one operator is its second row's.

Loading editor

Nesting

Put a ConditionEditor inside an item to nest a set of conditions. The nested set is drawn as a group; there's no prop for it.

Loading editor

Surfaces

ConditionEditor expects to sit on surface-content. On any other background, set the connectorSurfaceColor CSS variable from @customerio/pluma-components/css to that background.

Nested groups alternate their surface by depth, however deep they go: a group on the top-level set is surface-block, a group inside that surface-recessed, the next surface-block again, and so on. Set groupBackgroundColor to use one surface at every depth.

Virtualized lists

Leave connectorPosition unset when rows render side by side in a ConditionEditor. In a virtualized list, where each row renders in its own wrapper and only some are rendered, set it on every row: 'first', 'middle' or 'last' by the row's index in the whole set, or 'only' for a set of one.

<ConditionEditor style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
	{virtualizer.getVirtualItems().map((item) => (
		<div key={item.key} style={{ position: 'absolute', insetInline: 0, transform: `translateY(${item.start}px)` }}>
			<ConditionEditorItem
				operator={item.index > 0 ? 'And' : undefined}
				connectorPosition={item.index === 0 ? 'first' : item.index === count - 1 ? 'last' : 'middle'}
			>
				…
			</ConditionEditorItem>
		</div>
	))}
</ConditionEditor>

Conditional slots in Ember

A named block can't be wrapped in {{#if}}. Write <:heading>, <:details>, <:footer> and <:actions> once and drive them with hasHeadingBlock, hasDetailsBlock, hasFooterBlock and hasActionsBlock: leave one undefined and its slot renders whenever it has content; set it and it wins.

<PlumaConditionEditorItem @hasDetailsBlock={{this.hasFilters}}>
	<:default>…</:default>
	<:details>…</:details>
</PlumaConditionEditorItem>

API

Default:'div'

What element or component should be rendered.

Explicitly controls whether the footer renders. Leave undefined to render it whenever it has content.

Explicitly controls whether the header's actions render. Leave undefined to render them whenever they have content.

Explicitly controls whether the header renders. Leave undefined to render it whenever it has content.

A line above the rows describing the set: how its conditions combine ("Any of the following conditions match"), with the control that changes it. It lines up with the cards and names the set for assistive technology. What the control is and what it means are up to your application.

Buttons at the trailing edge of the header, such as duplicate or remove the group. They line up with the rows' actions.

Trailing slot inside the card (duplicate, remove, status). Each child is centered on the first line of controls.

Where the row sits on its set's connector. Leave it unset and the connector is worked out from the rows rendered beside it. Set it when the rows aren't rendered as siblings — in a virtualized list, each row is alone in its own positioned wrapper and only some are rendered — so the connector still runs through the set: 'first', 'middle' or 'last', or 'only' for a set of one, which has no connector.

Content stacked below the first line of controls: refinements, filter rows, an "add filter" button.

Draws the line that shows where a dragged row will land: 'before' this row or 'after' it. The line falls on the same pixels whichever side of a slot marks it, and starts at the card's leading edge, so its indent shows which set the row lands in. It sets data-drop-indicator on the row, which a drag layer can also set directly to move the line without re-rendering.

Receives the handle's button when it renders, and null when it's removed — the element to give a drag-and-drop library as its handle. In React, any ref works, so dnd-kit's handleRef can be passed directly.

Explicitly controls whether the actions slot renders. Leave undefined to render it whenever it has content.

Explicitly controls whether the details slot renders. Leave undefined to render it whenever it has content.

Explicitly controls whether the footer renders. Leave undefined to render it whenever it has content.

Explicitly controls whether the heading slot renders. Leave undefined to render it whenever it has content.

A caption above the controls, inside the card: what kind of condition the row is ("Object attribute"), with an icon if you like. The handle, the connector's branch and the actions meet this line instead of the controls, and it names the row's controls for assistive technology.

Default:false

Disables the reorder handle while keeping it visible.

Called with the option's value when one is picked from operatorOptions.

Text for the label floating in the gap above the card — the operator that joins this row to the one before it, in sentence case ("And", "Or"). Rendered as a Badge, the same one ConditionViewer group labels use. Leave it unset on the first row.

Makes the operator a control, together with onOperatorChange: the label becomes a small Select of these options, the same control as a group's match and the row's own fields, and picking one calls onOperatorChange. Without both, the operator is the read-only label, so each row can be switchable or not. operator is then the chosen option's value, and the label reads its label. The change is this row's alone; whether it relabels any other row is up to your application. Rows in one set read as one expression, so mixed operators in a set are ambiguous: nest a ConditionEditor for the part that binds tighter.

Default:false

Renders the reorder handle beside the card. The handle doesn't drag on its own: pass its element to a drag-and-drop library with handleRef.