Flow
A pipeline, dependency graph or database schema on a pannable canvas: steps with a status, smooth edges, rows joined row to row, auto layout, draggable nodes saved to localStorage, a hover card and a context menu.
c2-flow draws a directed graph such as a CI pipeline, a data job or a training run. Give it nodes and edges (as properties, or as JSON attributes); every node carries a status, and each edge takes its look from the statuses at both ends: an edge into a running step animates, one out of a failed step turns red and one touching a skipped step is dotted. Every edge ends in an arrowhead at its target, in the edge’s own colour, so the direction of a dependency reads at a glance. An edge with a label shows it halfway along, in a small pill: the “yes” and “no” on the branches of a decision.
The flow lays itself out: one column per dependency depth (direction="LR") or one row ("TB"), with siblings ordered to avoid crossings. Cycles are allowed and their closing edge loops round the outside. Drag a node to arrange the graph yourself; with a storage-key the arrangement and the chosen direction are saved in localStorage and restored on the next visit, and when the graph has changed in between, the saved layout is merged: kept steps stay put, new ones are placed next to their neighbours.
Right-click the canvas or a node (or long-press it, or press Shift+F10) for the view controls: zoom in, zoom out, fit view, layout direction, auto layout and lock layout. With focus in the flow, Ctrl/⌘ + +, Ctrl/⌘ + - and Ctrl/⌘ + 0 zoom and fit, and Ctrl/⌘ + scroll zooms around the pointer. Hover or focus a node for a card with its status and details.
Import
import '@c2n/components/flow'The examples use icons from the separate @c2n/feather-icons package: npm install @c2n/feather-icons.
The entry also registers c2-context-menu and c2-menu, which it renders internally.
Usage
Drag the steps, hover them, and right-click the canvas. The fourth row draws its nodes with renderNode; the fifth is a plain decision diagram, with labelled branches and the status marker hidden; the sixth places its nodes itself and runs edges between chosen sides (see Sides); the seventh is editable, with an action toolbar on its left edge (add node, auto layout, fit view): double-click the canvas or use + to add a note, drag one of the four handles on a node’s sides to connect it from that side, double-click a node or an edge to rename it or label it, and press Delete to remove what you selected. The last one is a database schema (see Rows and schema diagrams): hover a column to follow its foreign key, drag the handle of a column onto a column of another table to link them, click + 4 more on a long table to show its other columns, and drag a column by the grip at its start to reorder the table.
Editing
With editable the user draws the diagram. The flow stays controlled, like a form field with a value: it never changes nodes or edges itself, it fires an event and your application applies the change (and can refuse it, or save it first). None of the events bubble.
| Gesture | Keyboard | Event |
|---|---|---|
| Double-click empty canvas, or Add node in the canvas menu | N (beside the focused node, else in the middle of the view), or Enter on the canvas of an empty flow | node-add { position } |
| Drag a node’s side handle and release on empty canvas | N on the selected node | node-add { position, source, sourceSide, targetSide } |
| Drag a node’s side handle onto another node | C, then the arrow keys or Tab to choose, Enter to connect, Escape to cancel | edge-add { source, target, sourceSide, targetSide } |
| Double-click a node, or Rename in its menu | F2, or Enter on the selected node; Enter commits, Escape cancels | node-edit { id, label } |
| Double-click an edge or its label, or Edit label in its menu | F2 or Enter on the selected edge; Enter commits, Escape cancels | edge-edit { source, target, label } |
| Delete in a node’s menu | Delete or Backspace on the focused node | node-delete { id } |
| Click an edge, then Delete; or Delete in its menu | E on a node selects its edges in turn, then Delete | edge-delete { source, target } |
edge-add never names a self-loop or an edge that already exists. position is in canvas pixels, the top-left corner of the new node: give it to the node as its position and the flow puts it there. While the flow is editable, a change to the graph never moves the nodes that are already on the canvas: their positions are pinned as a custom layout, saved under storage-key and reported by layout-change with the reason edit. A node the keyboard or addNode() asked for is selected and focused once you add it, and the view pans to show it when it landed out of sight.
An editable node shows a handle in the middle of each of its four sides while it is hovered, focused or selected, and a connection leaves by the side whose handle was dragged. Released on a node, it arrives by the handle it was dropped on, or else by the side of that node nearest the pointer; the handle of that side lights up while the connection is drawn. edge-add reports both as sourceSide and targetSide (top, right, bottom or left): add them to the edge and it is drawn between those sides. Dropped on empty canvas, node-add carries sourceSide and the new node’s targetSide, the side facing back to the source, which position puts on the drop point. A connection made with the keyboard (C) goes from the outgoing side of the direction to the incoming one: right to left in LR, bottom to top in TB.
no-double-click-add turns off adding a node by double-clicking (or double-tapping) empty canvas, for an editor whose toolbar has an add button: a double-click on the canvas then never drops a node where the user only meant to deselect. Add node in the canvas menu, N and addNode() still add one.
An edge’s label is edited in the same inline field, opened halfway along the edge. edge-edit carries the trimmed new label, and an empty label when the user cleared it: remove the edge’s label then. editEdgeLabel(source, target) opens the field from script, for example on a branch you have just added.
The inline editor replaces the label of a node drawn by the flow. A node drawn by renderNode or a node:<id> slot gets the editor over its whole body and the same node-edit event, but must render node.label itself for the new name to show.
import type { FlowEdge, FlowEdgeEventDetail } from '@c2n/components/flow'
const flow = document.querySelector('c2-flow')!
let next = 1
flow.addEventListener('node-add', async ({ detail }) => {
const id = `note-${next++}`
flow.nodes = [...flow.nodes, { id, label: 'New note', position: detail.position }]
if (detail.source) flow.edges = [...flow.edges, { source: detail.source, target: id, sourceSide: detail.sourceSide, targetSide: detail.targetSide }]
await flow.updateComplete
flow.editLabel(id) // let the user name it straight away
})
flow.addEventListener('node-edit', ({ detail }) => {
flow.nodes = flow.nodes.map((node) => (node.id === detail.id ? { ...node, label: detail.label } : node))
})
flow.addEventListener('node-delete', ({ detail }) => {
flow.nodes = flow.nodes.filter((node) => node.id !== detail.id)
flow.edges = flow.edges.filter((edge) => edge.source !== detail.id && edge.target !== detail.id)
})
flow.addEventListener('edge-add', ({ detail }) => {
flow.edges = [...flow.edges, detail] // source, target, and sourceSide/targetSide or sourcePort/targetPort
})
// The same two nodes can be joined between several pairs of rows, so the rows are part of an edge's identity.
const same = (edge: FlowEdge, detail: FlowEdgeEventDetail) =>
edge.source === detail.source && edge.target === detail.target && edge.sourcePort === detail.sourcePort && edge.targetPort === detail.targetPort
flow.addEventListener('edge-delete', ({ detail }) => {
flow.edges = flow.edges.filter((edge) => !same(edge, detail))
})
flow.addEventListener('edge-edit', ({ detail }) => {
flow.edges = flow.edges.map((edge) => {
if (!same(edge, detail)) return edge
const next: FlowEdge = { ...edge, label: detail.label }
if (!detail.label) delete next.label
return next
})
})Actions on the canvas
Buttons slotted into actions form a toolbar over the canvas: the place for the commands a diagram editor keeps at hand, such as add node, auto layout or fit view. It sits at the top by default; actions-placement="right", "bottom" or "left" moves it to that edge, where the buttons stack in a column. It is a toolbar for assistive technology, and the arrow keys (along its orientation), Home and End move between its buttons. Clicks on it never pan the canvas or open the context menu.
Wire the buttons to the flow’s methods: addNode() asks for a node exactly as N does, through the usual node-add event: connected after the selected node, else beside the node that last had focus in the flow, else in a free spot in the middle of the view; repeated clicks with a node selected build a chain. dragNewNode(event), called from the same button’s pointerdown, lets the user drag the new node onto the canvas as a design tool drags a shape: a dashed placeholder (--c2-flow__ghost--border, --c2-flow__ghost--background-color) follows the pointer, and releasing it over the canvas asks for the node there, moved aside if it would cover another node; Escape or a release elsewhere, the toolbar included, cancels. A press that does not move, or comes back to the button, drops nothing, so the button’s click can still call addNode(). Give the button touch-action: none so a touch drag is not taken for a page scroll. resetLayout() animates back to the auto layout, and fitView(), zoomIn() and zoomOut() move the view. The panel is styled by --c2-flow__actions--background-color, --border, --border-radius, --box-shadow, --padding and --gap; --c2-flow__actions--align (flex-start, center, flex-end) slides it along its edge and --c2-flow__actions--offset sets its distance from the edge.
<c2-flow editable actions-placement="left">
<c2-icon-button slot="actions" data-action="add" tooltip="Add node" aria-label="Add node"><c2-feather-plus></c2-feather-plus></c2-icon-button>
<c2-icon-button slot="actions" data-action="layout" tooltip="Auto layout" aria-label="Auto layout"><c2-feather-layout></c2-feather-layout></c2-icon-button>
<c2-icon-button slot="actions" data-action="fit" tooltip="Fit view" aria-label="Fit view"><c2-feather-maximize></c2-feather-maximize></c2-icon-button>
</c2-flow>
<script type="module">
const flow = document.querySelector('c2-flow')
const run = { add: () => flow.addNode(), layout: () => flow.resetLayout(), fit: () => flow.fitView() }
flow.addEventListener('click', (event) => run[event.target.closest('[data-action]')?.dataset.action]?.())
</script>Arrowheads, labels and the status marker
All three are styled through CSS variables. The arrowhead takes the colour of its edge in every state (idle, done, active, failed, selected and the edge being drawn); --c2-flow__arrow--size sizes it and --c2-flow__arrow--display: none turns it off. An edge label is drawn in a pill with its own background, so it stays readable over the line and the dot grid: --c2-flow__edge-label--background-color, --c2-flow__edge-label--color, --c2-flow__edge-label--font-size, --c2-flow__edge-label--padding, --c2-flow__edge-label--border and --c2-flow__edge-label--border-radius. Labels scale with the canvas, like the text of the nodes, and screen readers hear them with the relations of both nodes (“Before Book the night bus, yes; Walk to the station, no.”).
The marker at the start of each default node body shows the step’s status, which is what a pipeline needs and noise in a plain diagram. --c2-flow__marker--display: none hides it, and the label then starts flush with the node’s padding; the hover card keeps its marker.
c2-flow.diagram {
--c2-flow__marker--display: none;
--c2-flow__arrow--size: 10px;
--c2-flow__edge-label--color: #18181b;
}Sides
An edge leaves its source by the outgoing side of the direction and arrives by the incoming side (right to left in LR, bottom to top in TB), and an edge closing a cycle loops round the outside of the nodes. Give it a sourceSide or a targetSide (top, right, bottom or left) and it runs from the middle of that side instead, leaving and arriving perpendicular to it, with either edge-type: a side left out keeps its default. A step edge goes round both nodes rather than through them, and so does a bezier edge whose curve would cut through one, for example into the far side of a node behind its source. The dots where edges meet a node, its arrowhead and its label follow. The auto layout does not read the sides, so give the nodes a position when the sides only make sense in a layout of your own.
<c2-flow
nodes='[{"id":"review","label":"Review","position":{"x":280,"y":0}},{"id":"fix","label":"Fix","position":{"x":280,"y":130}}, …]'
edges='[{"source":"review","target":"fix","sourceSide":"bottom","targetSide":"top","label":"changes"}, …]'
></c2-flow>Rows and schema diagrams
A node’s rows are drawn as a list under its label: the columns of a table, the fields of a class. Each row has an id, a label, an optional meta (a column type) and icon (a key), and details for a hover card of its own. Every row is a port: an edge that names it in sourcePort or targetPort leaves or arrives level with that row, on its left or right side, whichever faces the other end (or the one sourceSide / targetSide names). Two tables can be joined by several edges through different columns, and a table can be joined to itself between two of its columns.
sourceMarker and targetMarker draw the ends of an edge: an arrow (the default at the target), none (the default at the source), or a crow’s-foot cardinality, one, zero-one, many, one-many or zero-many, in the edge’s colour and width. --c2-flow__edge-marker--size sizes them.
<c2-flow
edge-type="step"
nodes='[{"id":"customers","label":"customers","rows":[{"id":"id","label":"id","meta":"uuid"}, …]},
{"id":"orders","label":"orders","rows":[{"id":"id","label":"id","meta":"uuid"},{"id":"customer_id","label":"customer_id","meta":"uuid"}, …]}]'
edges='[{"source":"customers","sourcePort":"id","target":"orders","targetPort":"customer_id","sourceMarker":"one","targetMarker":"many"}]'
></c2-flow>Hovering a row highlights its edges and the rows at their far ends. Screen readers hear a node’s rows and the rows each of its edges joins (“After customers.id through customer_id.”). In an editable flow a node with rows is connected row to row: each row shows a handle on both sides, and edge-add names the rows in sourcePort and targetPort (as do node-add, edge-delete and edge-edit), so the application can store them with the cardinality it chooses. A body drawn by renderNode or a node:<id> slot marks its own ports with data-flow-port="<id>".
A long table can fold its columns: row-limit="4" on the flow (or rowLimit on one node) shows the first four rows and folds the rest into a + N more row, whose hover card lists them. Rows an edge meets always show, so no foreign key disappears. A click on that row, Shift+Enter on the table or Show all rows in its context menu shows every row (rows-toggle), and toggleRows(id, expanded?) does the same from script. With a storage-key, the open tables are saved with the layout.
In an editable flow the rows can be reordered. A hovered row shows a grip at its start: drag it and the row moves among the table’s shown rows as you go, its links with it, and on release the flow fires row-move with the row and its from and to indices in rows (Escape puts it back). Like every edit, the flow does not apply it: move the row in rows. From the keyboard, R on a focused table picks its first row, the arrows pick another, Alt with an arrow moves it and Enter or Escape ends; a row’s context menu also has Move row up and Move row down.
flow.addEventListener('row-move', ({ detail }) => {
flow.nodes = flow.nodes.map((node) => {
if (node.id !== detail.id) return node
const rows = [...node.rows!]
rows.splice(detail.to, 0, ...rows.splice(detail.from, 1))
return { ...node, rows }
})
})The rows are styled through --c2-flow__row--*, --c2-flow__row-meta--*, --c2-flow__row-grip--* and --c2-flow__row-more--*; a schema usually also hides the status marker and sets a monospace font:
c2-flow.schema {
--c2-flow__marker--display: none;
--c2-flow__node--width: 240px;
--c2-flow__row--font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
--c2-flow__row__highlighted--background-color: rgba(2, 101, 220, 0.08);
}Custom nodes and cards
renderNode returns the body of every node and renderCard the content of its hover card, as a Lit template, a DOM node or a string. Handles, dragging, edges and positioning stay with the flow. A framework that renders its own markup can use the node:<id> and card:<id> slots instead.
import { html } from 'lit'
import '@c2n/components/flow'
const flow = document.querySelector('c2-flow')!
flow.renderNode = ({ node, status }) => html`
<strong>${node.label}</strong>
<progress max="100" value=${node.data.progress}></progress>
<small>${status} · ${node.data.metric}</small>
`
flow.addEventListener('node-click', ({ detail }) => openDetails(detail.node))Context menu
renderContextMenu receives the node that was right-clicked (or null for the canvas), the edge in an editable flow, and the built-in rows (in an editable flow, Add node on the canvas, Rename and Delete on a node, and Edit label and Delete on an edge). The view controls (zoom, fit, layout) belong to the canvas: a node has no built-in rows, so right-clicking one opens a menu only when you return rows for it. Return your own rows next to defaultItems to extend the canvas menu; they fire flow-menu-select with the node. A row with value="flow:details" selects the node and fires node-click. Return undefined to keep the built-in rows, or null to let the browser show its own menu.
flow.renderContextMenu = ({ node }) =>
node
? html`<h6>${node.label}</h6>
<c2-menu-item value="flow:details">Show details</c2-menu-item>
${node.status === 'error' ? html`<c2-menu-item value="retry">Retry</c2-menu-item>` : nothing}`
: undefined
flow.addEventListener('flow-menu-select', ({ detail }) => {
if (detail.value === 'retry') retry(detail.node!.id)
})Layout
The saved layout is a plain object of positions keyed by node id. getLayout() returns the current one and setLayout() applies one (merged like a restored layout), so it can live on a server instead of in localStorage; layout-change fires whenever the user changes it. resetLayout() goes back to the auto layout, and fitView(), zoomIn() and zoomOut() drive the view from your own controls.
Fitting scales the whole graph into the box, never above 100%; it happens on the first render, on fitView(), and when the box is resized or the data changes before the user has panned or zoomed. fit-min-zoom (0.25 to 1, default 0.25) is the least zoom it may choose, for a small preview whose labels must stay readable: a flow too large for its box at that zoom shows its start instead of its middle, the first step at the left edge (direction="LR") or the top edge ("TB"), centred across when it fits that way. Changing it refits an untouched view. Zooming by hand still goes from 25% to 200%.
<c2-flow class="preview" locked no-context-menu no-card fit-min-zoom="0.6"></c2-flow>A node can also carry its own position. The flow places it there instead of where the auto layout would, unless the user has already moved it (a saved or dragged position wins over the one the node first arrived with); changing the value later moves the node. Nodes without one still follow the auto layout, placed next to their neighbours.