Foundations

Connecting

Joining one port to another by a drag, by two presses or by keys, with the answer shown before the hand lets go.

Markdown

Connecting is joining one thing to another by hand: a line pulled from a port on one node to a port on another, a wire from a pin to a socket. It is a drag with a question in it — may it end here? — and the answer should be visible before the hand lets go.

A line that asks before it lands

Pull a line from Read's output: inputs that can take it are ringed, the rest fade. Bring it near one and it jumps the last few pixels. Try an output, or a port that carries something else, or the port it is already joined to: refused, with the reason. Press Escape half-way. Then do it all with Enter and the arrow keys.

Layout
Lines
Read orders
orders.csv
Read customers
customers.csv
Filter
date ≥ 1 July
Join
on customer
Summarise
total by region
Write table
regions.parquet
Notify
#sales
Read orders rows out to Filter rows in
Filter rows out to Join left rows in
Read customers rows out to Join right rows in
Join rows out to Summarise rows in
Summarise rows out to Write table rows in
Summarise summary out to Notify words in, daily

7 steps, 6 lines

Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { rectUnion } from "@publictheta/ui/algorithms/geometry.ts";
import { Diagram, DiagramNode, DiagramPort } from "@publictheta/ui/components/diagram/diagram.tsx";
import type { DiagramRefusal } from "@publictheta/ui/components/diagram/diagram.tsx";
import {
  ContextMenu,
  ContextMenuTrigger,
  MenuContent,
  MenuItem,
} from "@publictheta/ui/components/menu/menu.tsx";
import type { Point } from "@publictheta/ui/algorithms/geometry.ts";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import {
  applyGraphChanges,
  autoLayout,
  connection,
  findPort,
  insertion,
  invertGraphChanges,
  keepLaidOut,
  pinning,
} from "@publictheta/ui/primitives/graph.ts";
import type {
  GraphChange,
  GraphData,
  GraphEdge,
  GraphNode,
  GraphPort,
  LayoutOptions,
  PortRef,
} from "@publictheta/ui/primitives/graph.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { IDENTITY, fit } from "@publictheta/ui/primitives/viewport.ts";
import type { Viewport } from "@publictheta/ui/primitives/viewport.ts";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import type { KeyboardEvent } from "react";
import { Controls } from "../../ui/controls.tsx";

interface Step extends GraphNode {
  readonly title: string;
  readonly detail: string;
}

type Change = GraphChange<Step>;

const WIDTH = 128;
const HEIGHT = 52;
// The least zoom at which a step's words can still be read.
const READABLE = 0.7;
const LAYOUT: LayoutOptions = { direction: "right", layerGap: 56, nodeGap: 20 };

// Rows flow from step to step; a summary is words, and only a step that takes words takes it.
// Each side of a join takes one line; any other input takes as many as are given it.
const rowsIn = (id = "in", limit?: number): GraphPort => ({
  id,
  side: "left",
  direction: "in",
  type: "rows",
  limit,
});
const rowsOut: GraphPort = { id: "out", side: "right", direction: "out", type: "rows" };
const NAMES: Readonly<Record<string, string>> = {
  in: "rows in",
  left: "left rows in",
  right: "right rows in",
  out: "rows out",
  summary: "summary out",
  words: "words in",
};

const step = (
  id: string,
  title: string,
  detail: string,
  x: number,
  y: number,
  ports: readonly GraphPort[],
): Step => ({ id, title, detail, rect: { x, y, width: WIDTH, height: HEIGHT }, ports });

const STEPS: readonly Step[] = [
  step("orders", "Read orders", "orders.csv", 0, 0, [rowsOut]),
  step("customers", "Read customers", "customers.csv", 0, 104, [rowsOut]),
  step("recent", "Filter", "date ≥ 1 July", 184, 0, [rowsIn(), rowsOut]),
  step("join", "Join", "on customer", 368, 52, [rowsIn("left", 1), rowsIn("right", 1), rowsOut]),
  step("total", "Summarise", "total by region", 552, 52, [
    rowsIn(),
    rowsOut,
    { id: "summary", side: "bottom", direction: "out", type: "words" },
  ]),
  step("write", "Write table", "regions.parquet", 736, 0, [rowsIn()]),
  step("notify", "Notify", "#sales", 736, 156, [
    { id: "words", side: "left", direction: "in", type: "words" },
  ]),
];

const join = (id: string, from: string, out: string, to: string, into: string): GraphEdge => ({
  id,
  source: { node: from, port: out },
  target: { node: to, port: into },
});

const EDGES: readonly GraphEdge[] = [
  join("e1", "orders", "out", "recent", "in"),
  join("e2", "recent", "out", "join", "left"),
  join("e3", "customers", "out", "join", "right"),
  join("e4", "join", "out", "total", "in"),
  join("e5", "total", "out", "write", "in"),
  { ...join("e6", "total", "summary", "notify", "words"), label: "daily" },
];

/** What can be added where a line is let go over nothing: each made with its port at that place. */
const OFFERS: ReadonlyArray<{
  readonly name: string;
  readonly make: (id: string, x: number, y: number) => Step;
}> = [
  { name: "Filter", make: (id, x, y) => step(id, "Filter", "rows → rows", x, y, [rowsIn(), rowsOut]) },
  { name: "Summarise", make: (id, x, y) => step(id, "Summarise", "rows → rows", x, y, [rowsIn(), rowsOut]) },
  { name: "Write table", make: (id, x, y) => step(id, "Write table", "table.parquet", x, y, [rowsIn()]) },
  { name: "Read file", make: (id, x, y) => step(id, "Read file", "file.csv", x, y, [rowsOut]) },
];

const REASONS: Readonly<Record<DiagramRefusal, string>> = {
  nowhere: "Nothing here can take that.",
  missing: "That is no longer there.",
  "same-node": "A step does not feed itself.",
  direction: "An output goes to an input, not to another output.",
  type: "A summary is words: it does not go where rows are expected.",
  duplicate: "Those two are already joined.",
  full: "That side of the join already has its line. Move the line, or remove it first.",
  cycle: "That would feed a step with its own result.",
};

const RULES = { acyclic: true } as const;

function renderStep(node: Step) {
  return (
    <DiagramNode
      aria-label={node.title}
      className="group/step cursor-default rounded-lg border border-border bg-surface px-3 py-2 shadow-sm data-pinned:border-dashed"
      data-pinned={node.pinned === true ? "" : undefined}
    >
      <div className="truncate text-sm leading-tight font-medium text-fg-strong">{node.title}</div>
      <div className="truncate text-xs text-fg-muted">{node.detail}</div>
      {node.pinned === true && (
        <svg
          aria-hidden
          viewBox="0 0 16 16"
          className="absolute end-1.5 top-1.5 size-3 text-fg-muted"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M6 2h4l-.5 4.5L12 9H4l2.5-2.5L6 2ZM8 9v5" />
        </svg>
      )}
      {node.ports.map((port) => (
        <DiagramPort
          key={port.id}
          port={port.id}
          // What carries words is square, what carries rows round: told apart without colour.
          className={port.type === "words" ? "rounded-xs" : undefined}
        />
      ))}
    </DiagramNode>
  );
}

const portName = (_: Step, port: GraphPort) => NAMES[port.id] ?? port.id;
const nodeName = (node: Step) => node.title;

export default function DiagramPipeline() {
  const [graph, setGraph] = useState<GraphData<Step>>({ nodes: STEPS, edges: EDGES });
  const [selected, setSelected] = useState<readonly string[]>([]);
  const [view, setView] = useState<Viewport>(IDENTITY);
  const [layout, setLayout] = useState<"hand" | "kept">("hand");
  const [line, setLine] = useState<"orthogonal" | "curve">("orthogonal");
  const [refused, setRefused] = useState<string | null>(null);
  const [history] = useState(() => createHistory<readonly Change[]>());
  const [, drawAgain] = useState(0);
  const canvas = useRef<HTMLDivElement>(null);
  const made = useRef({ steps: 0, edges: 0 });
  // A line let go over nothing: from where, and the place on the plane, while the menu of what to add there is open.
  const [offer, setOffer] = useState<{
    readonly from: PortRef;
    readonly point: Point;
    readonly id: string;
  } | null>(null);
  const offerAt = useRef<HTMLDivElement>(null);

  const size = () => ({ width: canvas.current?.clientWidth ?? 0, height: canvas.current?.clientHeight ?? 0 });
  const fitAll = (nodes: readonly Step[] = graph.nodes) => {
    const bounds = rectUnion(nodes.map((node) => node.rect));
    if (bounds === null) return;
    setView(fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } }));
  };
  // Begin with the whole pipeline in view, once the canvas has a size — unless that would make its
  // words too small to read, as on a phone: then from its start, readable, with the rest a pan away.
  const fitFirst = () => {
    const bounds = rectUnion(STEPS.map((node) => node.rect))!;
    const whole = fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } });
    if (whole.zoom >= READABLE) {
      // oxlint-disable-next-line react/set-state-in-effect
      setView(whole);
      return;
    }
    const { height } = size();
    setView({
      x: bounds.x - 16 / READABLE,
      y: bounds.y + bounds.height / 2 - height / READABLE / 2,
      zoom: READABLE,
    });
  };
  // oxlint-disable-next-line react/exhaustive-effect-dependencies -- once
  useEffect(fitFirst, []);

  /** Make changes: with the layout kept, everything not pinned takes its place again, in the same step of history. */
  function change(changes: readonly Change[], kept = layout === "kept"): GraphData<Step> {
    const all = kept ? keepLaidOut(graph, changes, LAYOUT) : changes;
    if (all.length === 0) return graph;
    const next = applyGraphChanges(graph, all);
    history.push(all);
    setGraph(next);
    setRefused(null);
    return next;
  }

  function travel(back: boolean): void {
    const entry = back ? history.undo() : history.redo();
    if (entry === undefined) return;
    setGraph(applyGraphChanges(graph, back ? invertGraphChanges(entry) : entry));
    setRefused(null);
    drawAgain((count) => count + 1);
  }

  const makeEdge = (source: PortRef, target: PortRef): GraphEdge => {
    made.current.edges += 1;
    return { id: `line-${made.current.edges}`, source, target };
  };

  function addStep(): void {
    made.current.steps += 1;
    const after = graph.nodes.find((node) => node.id === selected[0] && selected.length === 1);
    // Beside the step that is selected, or beneath them all: never on top of one.
    const all = rectUnion(graph.nodes.map((node) => node.rect)) ?? { x: 0, y: 0, width: 0, height: -20 };
    const added = newStep(
      `step-${made.current.steps}`,
      after === undefined ? all.x : after.rect.x + WIDTH + 56,
      after === undefined ? all.y + all.height + 20 : after.rect.y + HEIGHT + 20,
    );
    const changes: Change[] = [insertion(graph, [added])];
    // And joined to it, where it has an output free to give rows.
    const from = after?.ports.find((port) => port.direction === "out" && port.type === "rows");
    if (after !== undefined && from !== undefined) {
      const joined = connection(
        applyGraphChanges(graph, changes),
        { node: after.id, port: from.id },
        { node: added.id, port: "in" },
        makeEdge,
        RULES,
      );
      if (typeof joined !== "string") changes.push(joined);
    }
    const next = change(changes);
    setSelected([added.id]);
    fitAll(next.nodes);
  }

  /** The steps that could take a line from `from`, made at `point`, each with the change that adds and joins it. */
  function offered(from: PortRef, point: Point, id: string): Array<{ name: string; changes: Change[] }> {
    const port = findPort(graph, from)?.port;
    if (port === undefined) return [];
    return OFFERS.flatMap(({ name, make }) => {
      // Its port on the side that faces the line: an input's on the left, at the place; an output's on the right.
      const probe = make(id, 0, 0);
      const into = probe.ports.find((each) => each.direction !== port.direction);
      if (into === undefined) return [];
      const x = into.side === "left" ? point.x : point.x - WIDTH;
      const added = make(id, Math.round(x / 4) * 4, Math.round((point.y - HEIGHT / 2) / 4) * 4);
      const inserted = insertion(graph, [added]);
      const joined = connection(
        applyGraphChanges(graph, [inserted]),
        from,
        { node: id, port: into.id },
        (source, target) => ({ id: "", source, target }),
        RULES,
      );
      return typeof joined === "string" ? [] : [{ name, changes: [inserted] }];
    });
  }

  function add(from: PortRef, point: Point, id: string, name: string): void {
    const choice = offered(from, point, id).find((each) => each.name === name);
    if (choice === undefined) return;
    made.current.steps += 1;
    const [inserted] = choice.changes;
    const added = inserted!.kind === "insert" ? inserted!.nodes[0]!.item : undefined;
    if (added === undefined) return;
    const port = added.ports.find((each) => each.direction !== findPort(graph, from)?.port.direction)!;
    const joined = connection(
      applyGraphChanges(graph, [inserted!]),
      from,
      { node: added.id, port: port.id },
      makeEdge,
      RULES,
    );
    if (typeof joined === "string") return;
    change([inserted!, joined]);
    setSelected([added.id]);
  }

  function onKeyDown(event: KeyboardEvent): void {
    if (!isCommandKey(event) || event.key.toLowerCase() !== "z") return;
    event.preventDefault();
    travel(!event.shiftKey);
  }

  const chosen = graph.nodes.filter((node) => selected.includes(node.id));
  const pinned = chosen.length > 0 && chosen.every((node) => node.pinned === true);
  const lines = selected.length - chosen.length;

  return (
    <div className="flex w-full flex-col">
      <Controls>
        <RadioGroup
          label="Layout"
          size="small"
          value={layout}
          onValueChange={(next) => {
            setLayout(next);
            // Turned on, it begins at once.
            if (next === "kept") fitAll(change([], true).nodes);
          }}
        >
          <Radio value="hand">By hand</Radio>
          <Radio value="kept">Keep laid out</Radio>
        </RadioGroup>
        <RadioGroup label="Lines" size="small" value={line} onValueChange={setLine}>
          <Radio value="orthogonal">Square</Radio>
          <Radio value="curve">Curved</Radio>
        </RadioGroup>
      </Controls>
      <div className="mb-2 flex flex-wrap items-center gap-2">
        <Button onClick={addStep}>Add step</Button>
        <Button
          onClick={() => {
            const moves = autoLayout(graph, LAYOUT);
            fitAll(moves.length === 0 ? graph.nodes : change([{ kind: "move", moves }], false).nodes);
          }}
        >
          Lay out
        </Button>
        <Button
          pressed={pinned}
          disabled={chosen.length === 0}
          onClick={() => {
            const pin = pinning(
              graph,
              chosen.map((node) => node.id),
              !pinned,
            );
            if (pin !== null) change([pin]);
          }}
        >
          {pinned ? "Unpin" : "Pin"}
        </Button>
        <div className="ms-auto flex items-center gap-2">
          <Button focusableWhenDisabled disabled={!history.canUndo()} onClick={() => travel(true)}>
            Undo
          </Button>
          <Button focusableWhenDisabled disabled={!history.canRedo()} onClick={() => travel(false)}>
            Redo
          </Button>
          <Button onClick={() => fitAll()}>Fit</Button>
        </div>
      </div>
      <div className="relative">
        <Diagram<Step>
          ref={canvas}
          aria-label="Sales pipeline"
          graph={graph}
          onGraphChange={(changes) => change(changes)}
          renderNode={renderStep}
          nodeName={nodeName}
          portName={portName}
          createEdge={makeEdge}
          rules={RULES}
          line={line}
          onRefuse={(reason) => setRefused(REASONS[reason])}
          messages={{ refused: (reason) => REASONS[reason] }}
          value={selected}
          onValueChange={setSelected}
          viewport={view}
          onViewportChange={setView}
          grid={4}
          onKeyDown={onKeyDown}
          onDropOnEmpty={(from, point, client) => {
            const id = `step-${made.current.steps + 1}`;
            if (offered(from, point, id).length === 0) return;
            flushSync(() => setOffer({ from, point, id }));
            // Opened where the line was let go, as a menu asked for there.
            offerAt.current?.dispatchEvent(
              new MouseEvent("contextmenu", {
                bubbles: true,
                cancelable: true,
                clientX: client.x,
                clientY: client.y,
              }),
            );
          }}
          className="h-[420px] w-full rounded-lg border border-border-muted bg-surface-subtle max-sm:h-[360px]"
        />
        {/* The place the menu of what to add opens from. It takes no press of its own: it is only ever opened for a line let go. */}
        <ContextMenu
          onOpenChange={(open) => {
            if (!open) setOffer(null);
          }}
        >
          <ContextMenuTrigger ref={offerAt} aria-hidden className="pointer-events-none absolute inset-0" />
          <MenuContent aria-label="Add a step here">
            {offer !== null &&
              offered(offer.from, offer.point, offer.id).map(({ name }) => (
                <MenuItem key={name} onSelect={() => add(offer.from, offer.point, offer.id, name)}>
                  {`Add ${name}`}
                </MenuItem>
              ))}
          </MenuContent>
        </ContextMenu>
      </div>
      {/* The diagram says each of these itself, to those who cannot see it: this is for those who can. */}
      <p
        data-refused={refused === null ? undefined : ""}
        className="mt-6 text-center font-mono text-xs text-fg-muted data-refused:text-danger"
      >
        {refused !== null
          ? `Not joined. ${refused}`
          : selected.length === 0
            ? `${graph.nodes.length} steps, ${graph.edges.length} lines`
            : [
                chosen.length === 1 ? chosen[0]!.title : chosen.length > 1 ? `${chosen.length} steps` : "",
                lines === 1 ? "1 line" : lines > 1 ? `${lines} lines` : "",
              ]
                .filter(Boolean)
                .join(" and ") + " selected"}
      </p>
    </div>
  );
}

/** A step that takes rows and gives rows, to be made something of. */
function newStep(id: string, x: number, y: number): Step {
  return step(id, "New step", "rows → rows", Math.round(x / 4) * 4, Math.round(y / 4) * 4, [
    rowsIn(),
    rowsOut,
  ]);
}

Best practices

Show where it may end as soon as it begins. The moment a connection is begun, every port that can take it says so, and the rest draw back. People should not have to try a port to find out.

Draw the line as it goes. From the port it began at to the pointer; and once the pointer is within reach of a port that can take it, to that port, so that what will happen on release is what is already shown.

Be generous about where it ends. A release ends on the nearest port that can take it within reach, not only on one the pointer is exactly over. A hand aims at a small thing from a distance; reach further for a finger than for a mouse. Only ports that can take the connection draw it in: a port that cannot does not catch a line meant for its neighbour.

Refuse in the open. A pointer on a port that cannot take the connection shows the refusal there, on the port and on the line, while there is still time to go elsewhere. A release there joins nothing and says why.

Do not require a held button. A click or a tap on a port begins a connection, and a second on another port ends it. Holding a small thing down while crossing a screen is hard for many hands and impossible for some.

Give the keyboard the same thing. Enter on a port begins a connection, and focus goes to the nearest port that can take it. The arrow keys move among those ports by direction, since they are scattered on a plane; Tab goes through them in order. Enter joins; Escape gives it up and returns to where it began. Say what is happening: where the connection is from, and how many ports can take it.

Let people change their mind. Escape abandons a connection at any moment, however it was begun, and is not also taken by whatever is around it. A release over nothing joins nothing.

Move an end, do not make people begin again. The end of a line already drawn can be taken hold of and carried to another port. The line it was is not counted against it — it may return to the port it left — and until it is let go on a port that takes it, nothing has changed.

Carry the view along. A line drawn toward the edge of the view moves the view, so that a port out of sight can be reached; when a port that can take the line comes under the pointer, the view stops there.

Conventions

IntentPointerKeyboardTouch
BeginPress a port and drag; or clickEnter or Space on a portDrag from a port; or tap it
Choose whereMove toward a portArrow keys; Tab, Shift-TabMove toward a port
JoinRelease within reach; or clickEnterLift within reach; or tap
Give upEscape; release or press elsewhereEscapeLift or tap elsewhere
Move an endDrag the end of a selected lineEnter on the connection, in a listDrag the end of a selected line

A release is within reach of a port at 12 pixels for a mouse, 16 for a pen and 28 for a finger.

See also Dragging, Graphs and Diagram.

Behaviour

Reach

A release ends on the nearest port within reach; a point inside a port is no distance from it
// Two boxes ten wide, at 0 and at 30.
const boxes = [0, 0, 10, 10, 30, 0, 40, 10];
expect(nearestBox(boxes, null, 5, 5, 0)).toBe(0);
expect(nearestBox(boxes, null, 18, 5, 8)).toBe(0);
expect(nearestBox(boxes, null, 22, 5, 8)).toBe(1);
expect(nearestBox(boxes, null, 20, 5, 8)).toBe(-1);
// As near to both: the first.
expect(nearestBox(boxes, null, 20, 5, 10)).toBe(0);
expect(nearestBox(boxes, null, 13, 14, 5)).toBe(0);
expect(nearestBox([], null, 0, 0, 100)).toBe(-1);
Only the ports it may end on are in reach
const boxes = [0, 0, 10, 10, 30, 0, 40, 10];
expect(nearestBox(boxes, [0, 1], 5, 5, 30)).toBe(1);
expect(nearestBox(boxes, [0, 0], 5, 5, 30)).toBe(-1);
What is found is within reach, and nothing allowed is nearer
const coordinate = fc.integer({ min: -100, max: 100 });
const box = fc
  .tuple(coordinate, coordinate, fc.nat(40), fc.nat(40))
  .map(([x, y, width, height]) => [x, y, x + width, y + height]);
fc.assert(
  fc.property(
    fc.array(fc.tuple(box, fc.boolean()), { maxLength: 8 }),
    coordinate,
    coordinate,
    fc.nat(60),
    (given, x, y, reach) => {
      const far = given.map(([[left, top, right, bottom]]) =>
        Math.hypot(Math.max(left! - x, 0, x - right!), Math.max(top! - y, 0, y - bottom!)),
      );
      const found = nearestBox(
        given.flatMap(([each]) => each),
        given.map(([, open]) => (open ? 1 : 0)),
        x,
        y,
        reach,
      );
      const open = given.flatMap(([, allowed], index) =>
        allowed && far[index]! <= reach ? [index] : [],
      );
      if (found === -1) return open.length === 0;
      return open.includes(found) && open.every((index) => far[index]! >= far[found]!);
    },
  ),
);
A finger reaches further than a pen, and a pen than a mouse: 28 pixels, 16 and 12
expect(CONNECT_REACH).toEqual({ mouse: 12, pen: 16, touch: 28 });
expect(["mouse", "pen", "touch", ""].map(connectReach)).toEqual([12, 16, 28, 28]);

By a drag

A press on a port, dragged to another and released there, joins them
const onConnect = vi.fn();
await render(<Board onConnect={onConnect} />);
await commands.pressDragRelease(port("a.out"), port("b.in"));
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "b.in", undefined);
expect(state()).toBe("idle");
While it lasts every port says what it is to it: where it began, where it may end, where it would end now, and the rest
await render(<Board />);
expect(states()).toEqual({});
await commands.mouseDownAt(port("a.out"));
// To the middle of b.in.
await commands.mouseMoveBy(200, -60);
expect(state()).toBe("drag a.out → b.in");
expect(states()).toEqual({
  "a.out": "source",
  "b.in": "target",
  "c.in": "candidate",
  "d.out": "unavailable",
});
await commands.mouseUp();
expect(states()).toEqual({});
A release near a port it may end on ends on it: within twelve pixels for a mouse, and no further
const onConnect = vi.fn();
const onMiss = vi.fn();
await render(<Board onConnect={onConnect} onMiss={onMiss} />);
// Eight pixels short of b.in's edge.
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(182, -60);
expect(state()).toBe("drag a.out → b.in");
await commands.mouseUp();
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "b.in", undefined);
// Sixteen short.
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(174, -60);
expect(state()).toBe("drag a.out → nothing");
await commands.mouseUp();
expect(onConnect).toHaveBeenCalledOnce();
expect(onMiss).toHaveBeenCalledOnce();
A release on a port it may not end on is refused, and says which; nothing is joined
const onConnect = vi.fn();
const onRefuse = vi.fn();
await render(<Board onConnect={onConnect} onRefuse={onRefuse} />);
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(100, 80);
expect(state()).toBe("drag a.out → nothing, refused by d.out");
expect(el(port("d.out")).dataset.connectState).toBe("refused");
await commands.mouseUp();
expect(onRefuse).toHaveBeenCalledExactlyOnceWith("a.out", "d.out", undefined);
expect(onConnect).not.toHaveBeenCalled();
Being near a port it may not end on says nothing: only the pointer on it does
await render(<Board />);
await commands.mouseDownAt(port("a.out"));
// Six pixels to the left of d.out.
await commands.mouseMoveBy(84, 80);
expect(state()).toBe("drag a.out → nothing");
await commands.mouseUp();
A release on no port joins nothing, and says where it was
const onConnect = vi.fn();
const onMiss = vi.fn();
await render(<Board onConnect={onConnect} onMiss={onMiss} />);
// Where the release itself said it was: an emulated phone does not put the
// mouse on the exact pixel asked for.
const released = { x: Number.NaN, y: Number.NaN };
const hear = (event: PointerEvent) => {
  released.x = event.clientX;
  released.y = event.clientY;
};
document.addEventListener("pointerup", hear, { capture: true });
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(60, 0);
await commands.mouseUp();
document.removeEventListener("pointerup", hear, { capture: true });
expect(onConnect).not.toHaveBeenCalled();
const board = el('[data-testid="board"]').getBoundingClientRect();
const [from, at] = onMiss.mock.lastCall as [string, { x: number; y: number }];
expect(from).toBe("a.out");
expect(at).toEqual(released);
expect(Math.abs(at.x - board.left - 90)).toBeLessThan(2);
expect(Math.abs(at.y - board.top - 90)).toBeLessThan(2);
Escape during the drag takes it back: nothing is joined, and the release that follows does nothing
const onConnect = vi.fn();
const onMiss = vi.fn();
await render(<Board onConnect={onConnect} onMiss={onMiss} />);
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(200, -60);
await press("{Escape}");
expect(state()).toBe("idle");
await commands.mouseUp();
expect(onConnect).not.toHaveBeenCalled();
expect(onMiss).not.toHaveBeenCalled();
expect(state()).toBe("idle");
A drag from something that stands for a port — the end of a line — begins from that port, and brings back what it was given
const onConnect = vi.fn();
await render(<Board onConnect={onConnect} />);
await commands.pressDragRelease('[data-testid="grip"]', port("c.in"));
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "c.in", "moved");
Its beginning is told, with the ports it may end on
const onBegin = vi.fn();
await render(<Board onBegin={onBegin} />);
await commands.mouseDownAt(port("a.out"));
expect(onBegin).not.toHaveBeenCalled();
await commands.mouseMoveBy(20, 0);
expect(onBegin).toHaveBeenCalledExactlyOnceWith("a.out", "drag", ["b.in", "c.in"], undefined);
await commands.mouseUp();
await commands.clickSelector(port("d.out"));
expect(onBegin).toHaveBeenLastCalledWith("d.out", "pick", ["c.in"], undefined);
Each move of a drag is told, with the port it would end on, and its end: for a view to be moved along under it
const onDrag = vi.fn();
await render(<Board onDrag={onDrag} />);
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(50, 0, 2);
// To within the pixel an emulated phone may put the mouse off by.
expect(Math.abs((onDrag.mock.lastCall![0] as { dx: number }).dx - 50)).toBeLessThan(2);
expect(onDrag.mock.lastCall![1]).toBe(null);
// On to the middle of b.in.
await commands.mouseMoveBy(150, -60, 2);
expect(onDrag.mock.lastCall![1]).toBe("b.in");
await commands.mouseUp();
expect(onDrag).toHaveBeenLastCalledWith(null, null);
A finger's drag ends on a port within its wider reachChromium · touch, WebKit · touch

Not yet written.

By two presses

A click on a port begins from it, and a click on another ends there: nothing need be held down
const onConnect = vi.fn();
await render(<Board onConnect={onConnect} />);
await commands.clickSelector(port("a.out"));
expect(state()).toBe("pick a.out → nothing");
expect(states()).toMatchObject({ "a.out": "source", "b.in": "candidate", "d.out": "unavailable" });
await commands.clickSelector(port("c.in"));
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "c.in", undefined);
expect(state()).toBe("idle");
A tap on a port, then a tap on another, joins themChromium · touch, WebKit · touch

Not yet written.

A finger that rests on a port before lifting has pressed it all the same, and onceChromium · touch, WebKit · touch

Not yet written.

A click on a port it may not end on is refused, and it is still being made
const onConnect = vi.fn();
const onRefuse = vi.fn();
await render(<Board onConnect={onConnect} onRefuse={onRefuse} />);
await commands.clickSelector(port("a.out"));
await commands.clickSelector(port("d.out"));
expect(onRefuse).toHaveBeenCalledExactlyOnceWith("a.out", "d.out", undefined);
expect(state()).toBe("pick a.out → nothing");
await commands.clickSelector(port("b.in"));
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "b.in", undefined);
A press anywhere else gives it up, and so does a second click on the port it began from
const onConnect = vi.fn();
await render(<Board onConnect={onConnect} />);
await commands.clickSelector(port("a.out"));
await commands.clickSelector('[data-testid="board"]', { position: { x: 150, y: 100 } });
expect(state()).toBe("idle");
await commands.clickSelector(port("a.out"));
await commands.clickSelector(port("a.out"));
expect(state()).toBe("idle");
expect(onConnect).not.toHaveBeenCalled();
Escape gives it up, wherever focus is
await render(<Board />);
await commands.clickSelector('[aria-label="Before"]');
await commands.clickSelector(port("a.out"));
expect(state()).toBe("pick a.out → nothing");
await press("{Escape}");
expect(state()).toBe("idle");
The port under the mouse is where it would end, if it may end thereChromium, Firefox, WebKit

Not yet written.

By the keyboard

Enter on a port begins from it, and focus goes to the nearest port it may end on
await render(<Board />);
await focusOn("d.out");
await press("{Enter}");
expect(state()).toBe("pick d.out → c.in");
expect(focused()).toBe("c.in");
await press("{Escape}");
// From a.out both are as far: the first is taken.
await focusOn("a.out");
await press(" ");
expect(focused()).toBe("b.in");
The arrow keys move among the ports it may end on, by direction, and stay where nothing lies that way
await render(<Board />);
await focusOn("a.out");
await press("{Enter}");
await press("{ArrowDown}");
expect(focused()).toBe("c.in");
expect(state()).toBe("pick a.out → c.in");
// d.out lies to the left, but a connection from a.out may not end on it.
await press("{ArrowLeft}");
expect(focused()).toBe("c.in");
await press("{ArrowUp}");
expect(focused()).toBe("b.in");
Tab and Shift-Tab go through the ports it may end on, in order, round and round: Tab does not leave
await render(<Board />);
await focusOn("a.out");
await press("{Enter}");
const seen: Array<string | null | undefined> = [];
for (const keys of ["{Tab}", "{Tab}", "{Shift>}{Tab}{/Shift}", "{Shift>}{Tab}{/Shift}"]) {
  await press(keys);
  seen.push(focused());
}
expect(seen).toEqual(["c.in", "b.in", "c.in", "b.in"]);
Enter on a port it may end on joins it, and focus stays there
const onConnect = vi.fn();
await render(<Board onConnect={onConnect} />);
await focusOn("a.out");
await press("{Enter}{ArrowDown}{Enter}");
expect(onConnect).toHaveBeenCalledExactlyOnceWith("a.out", "c.in", undefined);
expect(state()).toBe("idle");
expect(focused()).toBe("c.in");
Escape gives it up and returns focus to the port it began from, and is not heard by what is around
const onConnect = vi.fn();
const onKeyDown = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- hears the keys the connection lets through
  <div onKeyDown={(event) => onKeyDown(event.key)}>
    <Board onConnect={onConnect} />
  </div>,
);
await focusOn("a.out");
await press("{Enter}{ArrowDown}");
onKeyDown.mockClear();
await press("{Escape}");
expect(state()).toBe("idle");
expect(focused()).toBe("a.out");
expect(onKeyDown).not.toHaveBeenCalled();
expect(onConnect).not.toHaveBeenCalled();
From a port with nowhere to go, Enter begins nothing, and says so
const onRefuse = vi.fn();
await render(<Board targets={() => []} onRefuse={onRefuse} />);
await focusOn("a.out");
await press("{Enter}");
expect(state()).toBe("idle");
expect(focused()).toBe("a.out");
expect(onRefuse).toHaveBeenCalledExactlyOnceWith("a.out", null, undefined);
Before a connection is begun, the arrow keys and Tab are not the ports'
const onKeyDown = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- hears the keys the ports let through
  <div onKeyDown={(event) => onKeyDown(event.key)}>
    <Board />
  </div>,
);
await focusOn("a.out");
await press("{ArrowDown}");
expect(onKeyDown).toHaveBeenCalledExactlyOnceWith("ArrowDown");
expect(focused()).toBe("a.out");

API

begin starts from a port by name, with something to bring back: focus goes to where it may end
const api: { current: ConnectResult<string> | null } = { current: null };
const onApi = (result: ConnectResult<string>) => void (api.current = result);
const onConnect = vi.fn();
await render(<Board onApi={onApi} onConnect={onConnect} />);
await userEvent.click(el('[aria-label="Before"]'));
expect(api.current!.begin("d.out", "again")).toBe(true);
await expect.poll(focused).toBe("c.in");
await press("{Enter}");
expect(onConnect).toHaveBeenCalledExactlyOnceWith("d.out", "c.in", "again");
cancel gives up whatever is being made; aimAgain looks again at what a resting pointer is over
const api: { current: ConnectResult<string> | null } = { current: null };
const onApi = (result: ConnectResult<string>) => void (api.current = result);
await render(<Board onApi={onApi} />);
await commands.mouseDownAt(port("a.out"));
await commands.mouseMoveBy(200, -60);
expect(state()).toBe("drag a.out → b.in");
// The port moves out from under the pointer, which does not move.
el(port("b.in")).style.left = "120px";
expect(api.current!.aimAgain()).toBe(null);
await expect.poll(state).toBe("drag a.out → nothing");
api.current!.cancel();
await expect.poll(state).toBe("idle");
await commands.mouseUp();
disabled, no connection is begun by any means
const onConnect = vi.fn();
await render(<Board disabled onConnect={onConnect} />);
await commands.pressDragRelease(port("a.out"), port("b.in"));
await commands.clickSelector(port("a.out"));
expect(state()).toBe("idle");
await focusOn("a.out");
await press("{Enter}");
expect(state()).toBe("idle");
expect(onConnect).not.toHaveBeenCalled();
Unmounting in the middle of a pick leaves no listener behind
const added = vi.spyOn(document, "addEventListener");
const screen = await render(<Board />);
await commands.clickSelector(port("a.out"));
const signals = added.mock.calls.flatMap(([, , options]) =>
  typeof options === "object" && options.signal !== undefined ? [options.signal] : [],
);
await screen.unmount();
added.mockRestore();
expect(signals.length).toBeGreaterThan(0);
expect(signals.every((signal) => signal.aborted)).toBe(true);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/connect.json
Source

src/primitives/connect.ts

/**
 * Joining one thing to another by hand: a port to a port, a pin to a socket.
 * What may be joined is the owner's to say — `targets` gives the ports a
 * connection from one may end on — and this owns the gesture.
 *
 * - **By a drag.** A press on a port that moves becomes a line out of it. It
 *   ends on the port it is released over — or the nearest it may end on within
 *   reach, a reach that is wider for a finger than for a mouse. Released on a
 *   port it may not end on, it is refused, and says which; released on nothing,
 *   it says where. Escape takes it back.
 * - **By two presses.** A click or a tap on a port begins from it, and one on
 *   another ends there: there is no need to hold anything down. A press
 *   anywhere else gives it up.
 * - **By the keyboard.** Enter or Space on a port begins from it, and focus
 *   goes to the nearest port it may end on. The arrow keys move among those by
 *   direction, Tab and Shift-Tab through them in order; Enter ends there, and
 *   Escape gives it up and returns to where it began.
 * - **Every port says what it is to the connection** while one lasts: where it
 *   began, where it may end, where it would end now, where it is refused.
 *
 * Ports are buttons; spread `portProps(key)` on each. Something else that a
 * connection can be dragged from — the end of a line already drawn — takes
 * `gripProps(key, payload)`, and the payload comes back with the result.
 */
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type {
  CSSProperties,
  FocusEvent,
  KeyboardEvent,
  MouseEvent,
  PointerEvent as ReactPointerEvent,
} from "react";
import { nearestInDirection } from "../algorithms/geometry.ts";
import type { Direction, Point, Rect } from "../algorithms/geometry.ts";
import { useDrag } from "./drag.ts";
import type { Drag } from "./drag.ts";

/** How near a port a release must be to end on it, in screen pixels. */
export const CONNECT_REACH = { mouse: 12, pen: 16, touch: 28 } as const;

/** The reach for a kind of pointer; one that is not known is given the widest. */
export function connectReach(pointerType: string): number {
  return pointerType === "mouse" || pointerType === "pen" ? CONNECT_REACH[pointerType] : CONNECT_REACH.touch;
}

/**
 * Which of some boxes a point would end on: the nearest of those `allowed`
 * that is within `reach` of it, a point inside a box being no distance from
 * it. `boxes` holds each as left, top, right, bottom; returns the box's number,
 * or -1. Of two as near, the first.
 */
export function nearestBox(
  boxes: ArrayLike<number>,
  allowed: ArrayLike<number> | null,
  x: number,
  y: number,
  reach: number,
): number {
  let best = -1;
  let nearest = Infinity;
  for (let index = 0; index * 4 < boxes.length; index += 1) {
    if (allowed !== null && !allowed[index]) continue;
    const dx = Math.max(boxes[index * 4]! - x, 0, x - boxes[index * 4 + 2]!);
    const dy = Math.max(boxes[index * 4 + 1]! - y, 0, y - boxes[index * 4 + 3]!);
    const distance = Math.hypot(dx, dy);
    if (distance <= reach && distance < nearest) {
      best = index;
      nearest = distance;
    }
  }
  return best;
}

/** A connection being made. */
export interface Connecting<P = undefined> {
  /** The port it began from. */
  readonly from: string;
  readonly payload: P | undefined;
  /** Held down and dragged, or begun and to be ended by a second press or the keyboard. */
  readonly how: "drag" | "pick";
  /** Where the pointer is, in the viewport's coordinates; `null` when there is none to follow. */
  readonly at: Point | null;
  /** The port it would end on, were it ended now. */
  readonly target: string | null;
  /** A port the pointer is on that it may not end on. */
  readonly refused: string | null;
}

export interface ConnectOptions<P = undefined> {
  readonly disabled?: boolean | undefined;
  /** The ports a connection from `from` may end on, in the order Tab goes through them. */
  readonly targets: (from: string, payload: P | undefined) => readonly string[];
  readonly onConnect: (from: string, to: string, payload: P | undefined) => void;
  /** Ended on a port it may not end on — or, `to` being `null`, begun where there is nowhere to go. */
  readonly onRefuse?: ((from: string, to: string | null, payload: P | undefined) => void) | undefined;
  /** A drag released on no port: where, in the viewport's coordinates. */
  readonly onMiss?: ((from: string, at: Point, payload: P | undefined) => void) | undefined;
  /** A connection was begun: how, and the ports it may end on. */
  readonly onBegin?:
    | ((from: string, how: "drag" | "pick", targets: readonly string[], payload: P | undefined) => void)
    | undefined;
  /**
   * Each move of a drag, with the port it would end on, and its end however it
   * ended (`null`): for moving a view along under it.
   */
  readonly onDrag?: ((drag: Drag | null, target: string | null) => void) | undefined;
}

export type PortState = "source" | "candidate" | "target" | "refused" | "unavailable";

export interface PortProps {
  readonly "data-connect": string;
  readonly "data-connect-port": string;
  readonly "data-connect-state": PortState | undefined;
  readonly style: CSSProperties;
  readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onPointerEnter: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onPointerLeave: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onClick: (event: MouseEvent<HTMLElement>) => void;
  readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
  readonly onFocus: (event: FocusEvent<HTMLElement>) => void;
}

export type GripProps = Pick<PortProps, "style" | "onPointerDown" | "onLostPointerCapture">;

export interface ConnectResult<P = undefined> {
  /** The connection being made, or `null`. */
  readonly connecting: Connecting<P> | null;
  /** To spread on a port: a button. */
  readonly portProps: (key: string) => PortProps;
  /** To spread on something else a connection from `key` is dragged from. */
  readonly gripProps: (key: string, payload?: P) => GripProps;
  /** Begin from a port, to be ended by the keyboard or a press: focus goes to the nearest port it may end on. */
  readonly begin: (from: string, payload?: P) => boolean;
  readonly cancel: () => void;
  /** Look again at what the pointer is over, the ports having moved under it: the port a drag would now end on. */
  readonly aimAgain: () => string | null;
}

const NONE: ReadonlySet<string> = new Set();
// A finger must not scroll the page out from under the line it is drawing.
const STYLE: CSSProperties = { touchAction: "none" };
const ARROWS: Readonly<Record<string, Direction>> = {
  ArrowLeft: "left",
  ArrowRight: "right",
  ArrowUp: "up",
  ArrowDown: "down",
};

export function useConnect<P = undefined>(options: ConnectOptions<P>): ConnectResult<P> {
  const scope = useId();
  const latest = useRef(options);
  useEffect(() => {
    latest.current = options;
  });
  const [connecting, setConnecting] = useState<Connecting<P> | null>(null);
  // As it stands now, for handlers that outlive the render they were made in.
  const now = useRef<Connecting<P> | null>(null);
  const order = useRef<readonly string[]>([]);
  const allowed = useRef<ReadonlySet<string>>(NONE);
  // The same set, for what is drawn from it.
  const [takers, setTakers] = useState<ReadonlySet<string>>(NONE);
  const pressed = useRef<{ from: string; payload: P | undefined } | null>(null);
  const pointer = useRef({ x: 0, y: 0, type: "mouse" });
  const began = useRef<HTMLElement | null>(null);
  // Whether the press now ending was a finger's, answered when it lifted.
  const lifted = useRef(false);

  const set = useCallback((next: Connecting<P> | null) => {
    now.current = next;
    setConnecting(next);
  }, []);

  const ports = useCallback(
    () => [...document.querySelectorAll<HTMLElement>(`[data-connect="${scope}"][data-connect-port]`)],
    [scope],
  );
  const portOf = useCallback(
    (key: string) => ports().find((element) => element.dataset.connectPort === key) ?? null,
    [ports],
  );

  /** What a pointer at a place would end on, and what it is on that refuses it. */
  const aim = useCallback(
    (x: number, y: number, pointerType: string, from: string) => {
      const elements = ports().filter((element) => element.dataset.connectPort !== from);
      const boxes = new Float64Array(elements.length * 4);
      const open = new Uint8Array(elements.length);
      for (const [index, element] of elements.entries()) {
        const box = element.getBoundingClientRect();
        boxes.set([box.left, box.top, box.right, box.bottom], index * 4);
        open[index] = allowed.current.has(element.dataset.connectPort!) ? 1 : 0;
      }
      const target = nearestBox(boxes, open, x, y, connectReach(pointerType));
      // Refused only by a port the pointer is on: being near one it may not end on says nothing.
      const on = target === -1 ? nearestBox(boxes, null, x, y, 0) : -1;
      return {
        target: target === -1 ? null : elements[target]!.dataset.connectPort!,
        refused: on === -1 ? null : elements[on]!.dataset.connectPort!,
      };
    },
    [ports],
  );

  const open = useCallback(
    (from: string, payload: P | undefined, how: "drag" | "pick", at: Point | null): boolean => {
      const { targets, onRefuse, onBegin } = latest.current;
      const list = targets(from, payload);
      // A drag may still begin, to show there is nowhere to go; a pick with nowhere to go is not begun.
      if (list.length === 0 && how === "pick") {
        onRefuse?.(from, null, payload);
        return false;
      }
      order.current = list;
      const taking = new Set(list);
      allowed.current = taking;
      setTakers(taking);
      set({ from, payload, how, at, target: null, refused: null });
      onBegin?.(from, how, list, payload);
      return true;
    },
    [set],
  );

  const drag = useDrag({
    onPress(press) {
      if (latest.current.disabled === true || pressed.current === null) return false;
      pointer.current = { x: press.x, y: press.y, type: press.pointerType };
      return true;
    },
    onStart(start) {
      const { from, payload } = pressed.current!;
      open(from, payload, "drag", { x: start.x, y: start.y });
    },
    onMove(move) {
      const current = now.current;
      if (current === null) return;
      pointer.current = { x: move.x, y: move.y, type: move.pointerType };
      const aimed = aim(move.x, move.y, move.pointerType, current.from);
      set({ ...current, at: { x: move.x, y: move.y }, ...aimed });
      latest.current.onDrag?.(move, aimed.target);
    },
    onEnd(end) {
      const current = now.current;
      if (current === null) return;
      const { onConnect, onRefuse, onMiss, onDrag } = latest.current;
      // Aimed once more: the release is where the pointer is now, whatever the last move found.
      const { target, refused } = aim(end.x, end.y, end.pointerType, current.from);
      set(null);
      onDrag?.(null, null);
      if (target !== null) onConnect(current.from, target, current.payload);
      else if (refused !== null) onRefuse?.(current.from, refused, current.payload);
      else onMiss?.(current.from, { x: end.x, y: end.y }, current.payload);
    },
    onCancel() {
      set(null);
      latest.current.onDrag?.(null, null);
    },
  });

  const cancel = useCallback(() => {
    const current = now.current;
    if (current === null) return;
    if (current.how === "drag") {
      drag.cancel();
      return;
    }
    set(null);
    // Focus goes back to where the connection began, if it was among the ports that it left.
    const active = document.activeElement;
    if (active instanceof HTMLElement && active.dataset.connect === scope) {
      began.current?.focus({ preventScroll: true });
    }
  }, [drag, scope, set]);

  const begin = useCallback(
    (from: string, payload?: P): boolean => {
      if (latest.current.disabled === true || !open(from, payload, "pick", null)) return false;
      const source = portOf(from);
      began.current = document.activeElement instanceof HTMLElement ? document.activeElement : source;
      // To the nearest port it may end on, from the one it began at.
      const candidates = ports().filter((element) => allowed.current.has(element.dataset.connectPort!));
      const centre = (element: HTMLElement | null) => {
        const box = element?.getBoundingClientRect();
        return box === undefined
          ? { x: 0, y: 0 }
          : { x: box.left + box.width / 2, y: box.top + box.height / 2 };
      };
      const origin = centre(source);
      let nearest: HTMLElement | null = null;
      let least = Infinity;
      for (const element of candidates) {
        const place = centre(element);
        const distance = Math.hypot(place.x - origin.x, place.y - origin.y);
        if (distance < least) {
          least = distance;
          nearest = element;
        }
      }
      nearest?.focus({ preventScroll: true });
      return true;
    },
    [open, portOf, ports],
  );

  /** End a pick on a port: join it, or be refused by it. */
  const end = useCallback(
    (current: Connecting<P>, key: string) => {
      const { onConnect, onRefuse } = latest.current;
      if (key === current.from) {
        cancel();
        return;
      }
      if (!allowed.current.has(key)) {
        // Still being made: the next press may be on a port that takes it.
        onRefuse?.(current.from, key, current.payload);
        return;
      }
      set(null);
      onConnect(current.from, key, current.payload);
    },
    [cancel, set],
  );

  // While a pick lasts, a press anywhere but on a port gives it up.
  const picking = connecting?.how === "pick";
  useEffect(() => {
    if (!picking) return undefined;
    const stop = new AbortController();
    document.addEventListener(
      "pointerdown",
      (event) => {
        const port = event.target instanceof Element ? event.target.closest("[data-connect-port]") : null;
        if (!(port instanceof HTMLElement) || port.dataset.connect !== scope) cancel();
      },
      { capture: true, signal: stop.signal },
    );
    // Wherever focus is: a pick begun by a press need not have moved it to a port.
    document.addEventListener(
      "keydown",
      (event) => {
        if (event.key !== "Escape") return;
        // Escape is the connection's own while there is one: it does not also clear what is around it.
        event.preventDefault();
        event.stopPropagation();
        cancel();
      },
      { capture: true, signal: stop.signal },
    );
    return () => stop.abort();
  }, [picking, scope, cancel]);

  const aimAgain = useCallback(() => {
    const current = now.current;
    if (current === null || current.how !== "drag") return null;
    const { x, y, type } = pointer.current;
    const aimed = aim(x, y, type, current.from);
    set({ ...current, ...aimed });
    return aimed.target;
  }, [aim, set]);

  const onLostPointerCapture = drag.props.onLostPointerCapture;
  const pressOn = drag.props.onPointerDown;

  // What the ports are to the connection: the same from one move of it to the next, until it changes.
  const from = connecting?.from;
  const target = connecting?.target;
  const refused = connecting?.refused;
  const stage = useMemo(
    () => (from === undefined ? null : { from, target, refused, allowed: takers }),
    [from, target, refused, takers],
  );

  const portProps = useCallback(
    (key: string): PortProps => {
      const state: PortState | undefined =
        stage === null
          ? undefined
          : key === stage.from
            ? "source"
            : key === stage.target
              ? "target"
              : key === stage.refused
                ? "refused"
                : stage.allowed.has(key)
                  ? "candidate"
                  : "unavailable";
      /** While a pick lasts, the port the pointer or focus is on is where it would end. */
      const point = (to: string | null) => {
        const picked = now.current;
        if (picked === null || picked.how !== "pick" || picked.target === to) return;
        if (to === null || allowed.current.has(to)) set({ ...picked, target: to });
      };
      return {
        "data-connect": scope,
        "data-connect-port": key,
        "data-connect-state": state,
        style: STYLE,
        onPointerDown(event) {
          pressed.current = { from: key, payload: undefined };
          lifted.current = false;
          pressOn(event);
        },
        // A finger let go where it was put down has pressed the port, however long it rested:
        // a browser that took the rest for a long press sends no click after it.
        onPointerUp(event) {
          if (event.pointerType === "mouse" || latest.current.disabled === true) return;
          const picked = now.current;
          if (picked?.how === "drag" || pressed.current?.from !== key) return;
          lifted.current = true;
          if (picked !== null) end(picked, key);
          else if (open(key, undefined, "pick", null)) began.current = event.currentTarget;
        },
        onLostPointerCapture,
        onPointerEnter(event) {
          if (event.pointerType === "mouse") point(key);
        },
        onPointerLeave(event) {
          if (event.pointerType === "mouse" && now.current?.target === key) point(null);
        },
        onFocus() {
          point(key);
        },
        // A button's click is also Enter's and Space's, and assistive technology's.
        onClick(event) {
          if (latest.current.disabled === true) return;
          // The click that follows a finger's lift has been answered already.
          if (lifted.current) {
            lifted.current = false;
            return;
          }
          const picked = now.current;
          if (picked !== null) {
            if (picked.how === "pick") end(picked, key);
            return;
          }
          // Pressed by a key, the way on is by keys: focus goes to where it may end.
          if (event.detail === 0) begin(key);
          else if (open(key, undefined, "pick", null)) began.current = event.currentTarget;
        },
        onKeyDown(event) {
          const picked = now.current;
          if (picked === null || picked.how !== "pick") return;
          const direction = ARROWS[event.key];
          if (event.key === "Tab") {
            // Round and round the ports it may end on: Tab does not leave until it is ended or given up.
            const list = order.current;
            const place = list.indexOf(key);
            const step = event.shiftKey ? -1 : 1;
            const next =
              place === -1
                ? list[event.shiftKey ? list.length - 1 : 0]
                : list[(place + step + list.length) % list.length];
            if (next !== undefined) portOf(next)?.focus({ preventScroll: true });
          } else if (direction !== undefined) {
            const here = event.currentTarget;
            const elements = [
              here,
              ...ports().filter(
                (element) => element !== here && allowed.current.has(element.dataset.connectPort!),
              ),
            ];
            const rects: Rect[] = elements.map((element) => {
              const box = element.getBoundingClientRect();
              return { x: box.left, y: box.top, width: box.width, height: box.height };
            });
            elements[nearestInDirection(rects, 0, direction)]?.focus({ preventScroll: true });
          } else {
            return;
          }
          // These keys are the connection's while it lasts, not whatever is around it's.
          event.preventDefault();
          event.stopPropagation();
        },
      };
    },
    [stage, scope, pressOn, onLostPointerCapture, set, end, begin, open, portOf, ports],
  );

  const gripProps = useCallback(
    (key: string, payload?: P): GripProps => ({
      style: STYLE,
      onPointerDown(event) {
        pressed.current = { from: key, payload };
        pressOn(event);
      },
      onLostPointerCapture,
    }),
    [pressOn, onLostPointerCapture],
  );

  return useMemo(
    () => ({ connecting, portProps, gripProps, begin, cancel, aimAgain }),
    [connecting, portProps, gripProps, begin, cancel, aimAgain],
  );
}