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.
MarkdownConnecting 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.
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
| Intent | Pointer | Keyboard | Touch |
|---|---|---|---|
| Begin | Press a port and drag; or click | Enter or Space on a port | Drag from a port; or tap it |
| Choose where | Move toward a port | Arrow keys; Tab, Shift-Tab | Move toward a port |
| Join | Release within reach; or click | Enter | Lift within reach; or tap |
| Give up | Escape; release or press elsewhere | Escape | Lift or tap elsewhere |
| Move an end | Drag the end of a selected line | Enter on the connection, in a list | Drag 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.
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.jsonSource
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],
);
}