@echothink-ui/motion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/components/AgentThinkingAnimation.d.ts +2 -0
- package/dist/components/AttentionPulse.d.ts +2 -0
- package/dist/components/DAGStatusTransition.d.ts +2 -0
- package/dist/components/DocumentLockPulse.d.ts +2 -0
- package/dist/components/PipelineFlowAnimation.d.ts +2 -0
- package/dist/components/ProgressTransition.d.ts +2 -0
- package/dist/components/SkeletonLoadingPattern.d.ts +2 -0
- package/dist/components/StatusChangeAnimation.d.ts +2 -0
- package/dist/components/StepCompletionAnimation.d.ts +2 -0
- package/dist/components/StreamingText.d.ts +2 -0
- package/dist/components/SyncProgressAnimation.d.ts +2 -0
- package/dist/components/motionUtils.d.ts +5 -0
- package/dist/components/types.d.ts +82 -0
- package/dist/index.cjs +2381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2333 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/components/AgentThinkingAnimation.tsx +59 -0
- package/src/components/AttentionPulse.tsx +57 -0
- package/src/components/DAGStatusTransition.tsx +292 -0
- package/src/components/DocumentLockPulse.tsx +72 -0
- package/src/components/PipelineFlowAnimation.tsx +243 -0
- package/src/components/ProgressTransition.tsx +51 -0
- package/src/components/SkeletonLoadingPattern.tsx +248 -0
- package/src/components/StatusChangeAnimation.test.tsx +20 -0
- package/src/components/StatusChangeAnimation.tsx +89 -0
- package/src/components/StepCompletionAnimation.tsx +75 -0
- package/src/components/StreamingText.tsx +77 -0
- package/src/components/SyncProgressAnimation.test.tsx +49 -0
- package/src/components/SyncProgressAnimation.tsx +256 -0
- package/src/components/motionUtils.tsx +942 -0
- package/src/components/types.ts +111 -0
- package/src/index.test.tsx +97 -0
- package/src/index.tsx +44 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { DAGStatusTransitionProps, MotionFlowNode } from "./types";
|
|
3
|
+
import { MotionStyles, statusColor, usePrefersReducedMotion } from "./motionUtils";
|
|
4
|
+
|
|
5
|
+
export function DAGStatusTransition({
|
|
6
|
+
nodes,
|
|
7
|
+
previousNodes,
|
|
8
|
+
edges = [],
|
|
9
|
+
className,
|
|
10
|
+
...props
|
|
11
|
+
}: DAGStatusTransitionProps) {
|
|
12
|
+
const reduced = usePrefersReducedMotion();
|
|
13
|
+
const previousStatus = new Map(previousNodes?.map((node) => [node.id, node.status]) ?? []);
|
|
14
|
+
const positions = layoutDag(nodes, edges);
|
|
15
|
+
const frame = viewBoxFor(nodes, positions);
|
|
16
|
+
const titleId = React.useId();
|
|
17
|
+
const descriptionId = React.useId();
|
|
18
|
+
const markerBaseId = React.useId();
|
|
19
|
+
const changedCount = nodes.filter(
|
|
20
|
+
(node) => previousStatus.has(node.id) && previousStatus.get(node.id) !== node.status
|
|
21
|
+
).length;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<section
|
|
25
|
+
{...props}
|
|
26
|
+
className={`eth-motion-dag-status ${reduced ? "eth-motion-reduced" : ""} ${className ?? ""}`}
|
|
27
|
+
data-eth-component="DAGStatusTransition"
|
|
28
|
+
aria-label="DAG status transition"
|
|
29
|
+
>
|
|
30
|
+
<MotionStyles />
|
|
31
|
+
<svg
|
|
32
|
+
className="eth-motion-dag-status__svg"
|
|
33
|
+
viewBox={frame.viewBox}
|
|
34
|
+
width={frame.width}
|
|
35
|
+
height={frame.height}
|
|
36
|
+
role="img"
|
|
37
|
+
aria-labelledby={`${titleId} ${descriptionId}`}
|
|
38
|
+
>
|
|
39
|
+
<title id={titleId}>DAG status transition</title>
|
|
40
|
+
<desc id={descriptionId}>
|
|
41
|
+
{`${nodes.length} nodes, ${edges.length} edges, ${changedCount} changed node statuses.`}
|
|
42
|
+
</desc>
|
|
43
|
+
<defs>
|
|
44
|
+
{edges.map((edge, index) => {
|
|
45
|
+
const to = positions.get(edge.to);
|
|
46
|
+
if (!to) return null;
|
|
47
|
+
const color = edgeColor(edge, nodes.find((node) => node.id === edge.to)?.status);
|
|
48
|
+
return (
|
|
49
|
+
<marker
|
|
50
|
+
key={`${edge.from}-${edge.to}-${index}`}
|
|
51
|
+
id={markerIdFromUseId(markerBaseId, index)}
|
|
52
|
+
markerWidth="8"
|
|
53
|
+
markerHeight="8"
|
|
54
|
+
refX="7"
|
|
55
|
+
refY="3"
|
|
56
|
+
orient="auto"
|
|
57
|
+
markerUnits="strokeWidth"
|
|
58
|
+
>
|
|
59
|
+
<path d="M0,0 L0,6 L7,3 z" fill={color} />
|
|
60
|
+
</marker>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</defs>
|
|
64
|
+
{edges.map((edge, index) => {
|
|
65
|
+
const from = positions.get(edge.from);
|
|
66
|
+
const to = positions.get(edge.to);
|
|
67
|
+
if (!from || !to) return null;
|
|
68
|
+
const color = edgeColor(edge, nodes.find((node) => node.id === edge.to)?.status);
|
|
69
|
+
const active = edge.active && !reduced;
|
|
70
|
+
return (
|
|
71
|
+
<path
|
|
72
|
+
key={`${edge.from}-${edge.to}-${index}`}
|
|
73
|
+
className="eth-motion-dag-status__edge"
|
|
74
|
+
d={edgePath(from, to)}
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke={color}
|
|
77
|
+
strokeWidth={active ? 2 : 1.25}
|
|
78
|
+
strokeDasharray={active ? "6 6" : undefined}
|
|
79
|
+
markerEnd={`url(#${markerIdFromUseId(markerBaseId, index)})`}
|
|
80
|
+
style={
|
|
81
|
+
active ? { animation: "eth-motion-flow-dash 900ms linear infinite" } : undefined
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
{nodes.map((node) => {
|
|
87
|
+
const point = positions.get(node.id);
|
|
88
|
+
if (!point) return null;
|
|
89
|
+
const previous = previousStatus.get(node.id);
|
|
90
|
+
const changed = previous !== undefined && previous !== node.status;
|
|
91
|
+
const statusText = changed
|
|
92
|
+
? `${compactLabelForStatus(previous)} -> ${compactLabelForStatus(node.status)}`
|
|
93
|
+
: labelForStatus(node.status);
|
|
94
|
+
return (
|
|
95
|
+
<g
|
|
96
|
+
key={node.id}
|
|
97
|
+
className={`eth-motion-dag-status__node ${changed ? "eth-motion-dag-status__node--changed" : ""}`}
|
|
98
|
+
transform={`translate(${point.x}, ${point.y})`}
|
|
99
|
+
>
|
|
100
|
+
{changed ? (
|
|
101
|
+
<rect
|
|
102
|
+
className="eth-motion-dag-status__change-ring"
|
|
103
|
+
x={-NODE_WIDTH / 2 - 4}
|
|
104
|
+
y={-NODE_HEIGHT / 2 - 4}
|
|
105
|
+
width={NODE_WIDTH + 8}
|
|
106
|
+
height={NODE_HEIGHT + 8}
|
|
107
|
+
rx={0}
|
|
108
|
+
fill="none"
|
|
109
|
+
stroke={statusColor(node.status)}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
<rect
|
|
113
|
+
className="eth-motion-dag-status__card"
|
|
114
|
+
x={-NODE_WIDTH / 2}
|
|
115
|
+
y={-NODE_HEIGHT / 2}
|
|
116
|
+
width={NODE_WIDTH}
|
|
117
|
+
height={NODE_HEIGHT}
|
|
118
|
+
rx={0}
|
|
119
|
+
fill="var(--cds-layer-02, #ffffff)"
|
|
120
|
+
stroke="var(--cds-border-subtle, #e0e0e0)"
|
|
121
|
+
/>
|
|
122
|
+
<rect
|
|
123
|
+
x={-NODE_WIDTH / 2}
|
|
124
|
+
y={-NODE_HEIGHT / 2}
|
|
125
|
+
width={4}
|
|
126
|
+
height={NODE_HEIGHT}
|
|
127
|
+
rx={0}
|
|
128
|
+
fill={statusColor(node.status)}
|
|
129
|
+
/>
|
|
130
|
+
<circle cx={-NODE_WIDTH / 2 + 20} cy={-9} r={4.5} fill={statusColor(node.status)} />
|
|
131
|
+
<text className="eth-motion-dag-status__label" x={-NODE_WIDTH / 2 + 34} y={-7}>
|
|
132
|
+
{truncate(node.label, 18)}
|
|
133
|
+
</text>
|
|
134
|
+
<text className="eth-motion-dag-status__status" x={-NODE_WIDTH / 2 + 34} y={15}>
|
|
135
|
+
{truncate(statusText, 24)}
|
|
136
|
+
</text>
|
|
137
|
+
<title>
|
|
138
|
+
{changed && previous
|
|
139
|
+
? `${node.label}: ${labelForStatus(previous)} to ${labelForStatus(node.status)}`
|
|
140
|
+
: `${node.label}: ${labelForStatus(node.status)}`}
|
|
141
|
+
</title>
|
|
142
|
+
</g>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
</svg>
|
|
146
|
+
<ol className="eth-motion-dag-status__fallback">
|
|
147
|
+
{nodes.map((node) => {
|
|
148
|
+
const previous = previousStatus.get(node.id);
|
|
149
|
+
const changed = previous !== undefined && previous !== node.status;
|
|
150
|
+
return (
|
|
151
|
+
<li key={node.id}>
|
|
152
|
+
{changed && previous
|
|
153
|
+
? `${node.label}: ${labelForStatus(previous)} to ${labelForStatus(node.status)}`
|
|
154
|
+
: `${node.label}: ${labelForStatus(node.status)}`}
|
|
155
|
+
</li>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
158
|
+
</ol>
|
|
159
|
+
</section>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const NODE_WIDTH = 160;
|
|
164
|
+
const NODE_HEIGHT = 64;
|
|
165
|
+
const NODE_GAP_X = 190;
|
|
166
|
+
const NODE_GAP_Y = 92;
|
|
167
|
+
|
|
168
|
+
function layoutDag(
|
|
169
|
+
nodes: MotionFlowNode[],
|
|
170
|
+
edges: NonNullable<DAGStatusTransitionProps["edges"]> = []
|
|
171
|
+
) {
|
|
172
|
+
const map = new Map<string, { x: number; y: number }>();
|
|
173
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
174
|
+
const ranks = new Map(nodes.map((node) => [node.id, 0]));
|
|
175
|
+
const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
|
|
176
|
+
|
|
177
|
+
for (let pass = 0; pass < nodes.length; pass += 1) {
|
|
178
|
+
for (const edge of validEdges) {
|
|
179
|
+
const fromRank = ranks.get(edge.from) ?? 0;
|
|
180
|
+
const toRank = ranks.get(edge.to) ?? 0;
|
|
181
|
+
if (toRank < fromRank + 1) ranks.set(edge.to, fromRank + 1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const groups = new Map<number, MotionFlowNode[]>();
|
|
186
|
+
for (const node of nodes) {
|
|
187
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
188
|
+
groups.set(rank, [...(groups.get(rank) ?? []), node]);
|
|
189
|
+
}
|
|
190
|
+
const maxRows = Math.max(1, ...Array.from(groups.values()).map((group) => group.length));
|
|
191
|
+
|
|
192
|
+
nodes.forEach((node, index) => {
|
|
193
|
+
const rank = ranks.get(node.id) ?? index;
|
|
194
|
+
const group = groups.get(rank) ?? [node];
|
|
195
|
+
const rowIndex = Math.max(
|
|
196
|
+
0,
|
|
197
|
+
group.findIndex((candidate) => candidate.id === node.id)
|
|
198
|
+
);
|
|
199
|
+
const row = group.length === 1 && maxRows > 1 ? (maxRows - 1) / 2 : rowIndex;
|
|
200
|
+
map.set(node.id, {
|
|
201
|
+
x: node.x ?? 88 + rank * NODE_GAP_X,
|
|
202
|
+
y: node.y ?? 64 + row * NODE_GAP_Y
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
return map;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function viewBoxFor(nodes: MotionFlowNode[], positions: Map<string, { x: number; y: number }>) {
|
|
209
|
+
if (!nodes.length) {
|
|
210
|
+
return { viewBox: "0 0 320 120", width: 320, height: 120 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const points = nodes
|
|
214
|
+
.map((node) => positions.get(node.id))
|
|
215
|
+
.filter((point): point is { x: number; y: number } => Boolean(point));
|
|
216
|
+
const minX = Math.floor(Math.min(...points.map((point) => point.x)) - NODE_WIDTH / 2 - 24);
|
|
217
|
+
const minY = Math.floor(Math.min(...points.map((point) => point.y)) - NODE_HEIGHT / 2 - 20);
|
|
218
|
+
const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + NODE_WIDTH / 2 + 30);
|
|
219
|
+
const maxY = Math.ceil(Math.max(...points.map((point) => point.y)) + NODE_HEIGHT / 2 + 20);
|
|
220
|
+
const width = maxX - minX;
|
|
221
|
+
const height = maxY - minY;
|
|
222
|
+
return { viewBox: `${minX} ${minY} ${width} ${height}`, width, height };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function edgePath(from: { x: number; y: number }, to: { x: number; y: number }) {
|
|
226
|
+
const horizontal = Math.abs(to.x - from.x) >= Math.abs(to.y - from.y);
|
|
227
|
+
if (horizontal) {
|
|
228
|
+
const direction = to.x >= from.x ? 1 : -1;
|
|
229
|
+
const start = { x: from.x + (NODE_WIDTH / 2) * direction, y: from.y };
|
|
230
|
+
const end = { x: to.x - (NODE_WIDTH / 2) * direction, y: to.y };
|
|
231
|
+
const controlOffset = Math.max(32, Math.abs(end.x - start.x) * 0.5);
|
|
232
|
+
return `M ${start.x} ${start.y} C ${start.x + controlOffset * direction} ${start.y}, ${end.x - controlOffset * direction} ${end.y}, ${end.x} ${end.y}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const direction = to.y >= from.y ? 1 : -1;
|
|
236
|
+
const start = { x: from.x, y: from.y + (NODE_HEIGHT / 2) * direction };
|
|
237
|
+
const end = { x: to.x, y: to.y - (NODE_HEIGHT / 2) * direction };
|
|
238
|
+
const controlOffset = Math.max(28, Math.abs(end.y - start.y) * 0.5);
|
|
239
|
+
return `M ${start.x} ${start.y} C ${start.x} ${start.y + controlOffset * direction}, ${end.x} ${end.y - controlOffset * direction}, ${end.x} ${end.y}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function edgeColor(
|
|
243
|
+
edge: NonNullable<DAGStatusTransitionProps["edges"]>[number],
|
|
244
|
+
toStatus?: MotionFlowNode["status"]
|
|
245
|
+
) {
|
|
246
|
+
if (edge.status) return statusColor(edge.status);
|
|
247
|
+
if (edge.active) return "var(--cds-interactive, #0f62fe)";
|
|
248
|
+
if (toStatus === "failed" || toStatus === "blocked") return statusColor(toStatus);
|
|
249
|
+
return "var(--cds-border-strong, #8d8d8d)";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function markerIdFromUseId(id: string, index: number) {
|
|
253
|
+
return `eth-motion-dag-arrow-${id.replace(/[^a-zA-Z0-9_-]/g, "")}-${index}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function labelForStatus(status: MotionFlowNode["status"]) {
|
|
257
|
+
switch (status) {
|
|
258
|
+
case "approval-required":
|
|
259
|
+
return "Approval required";
|
|
260
|
+
case "pending-approval":
|
|
261
|
+
return "Pending approval";
|
|
262
|
+
case "in-progress":
|
|
263
|
+
return "In progress";
|
|
264
|
+
case "not-started":
|
|
265
|
+
return "Not started";
|
|
266
|
+
default:
|
|
267
|
+
return status
|
|
268
|
+
.split("-")
|
|
269
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
270
|
+
.join(" ");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function compactLabelForStatus(status: MotionFlowNode["status"]) {
|
|
275
|
+
switch (status) {
|
|
276
|
+
case "approval-required":
|
|
277
|
+
return "Approval";
|
|
278
|
+
case "pending-approval":
|
|
279
|
+
return "Pending";
|
|
280
|
+
case "in-progress":
|
|
281
|
+
return "Progress";
|
|
282
|
+
case "not-started":
|
|
283
|
+
return "Not started";
|
|
284
|
+
default:
|
|
285
|
+
return labelForStatus(status);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function truncate(value: string, maxLength: number) {
|
|
290
|
+
if (value.length <= maxLength) return value;
|
|
291
|
+
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
292
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { DocumentLockPulseProps } from "./types";
|
|
3
|
+
import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
|
|
4
|
+
|
|
5
|
+
export function DocumentLockPulse({
|
|
6
|
+
lockedBy,
|
|
7
|
+
active = true,
|
|
8
|
+
className,
|
|
9
|
+
children,
|
|
10
|
+
role = "status",
|
|
11
|
+
style,
|
|
12
|
+
"aria-label": ariaLabel,
|
|
13
|
+
...props
|
|
14
|
+
}: DocumentLockPulseProps) {
|
|
15
|
+
const reduced = usePrefersReducedMotion();
|
|
16
|
+
const owner = children ?? lockedBy ?? "Document locked";
|
|
17
|
+
const ownerLabel = plainText(children ?? lockedBy);
|
|
18
|
+
const classes = [
|
|
19
|
+
"eth-motion-document-lock",
|
|
20
|
+
active ? "eth-motion-document-lock--active" : "eth-motion-document-lock--idle",
|
|
21
|
+
reduced ? "eth-motion-reduced" : undefined,
|
|
22
|
+
className
|
|
23
|
+
]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(" ");
|
|
26
|
+
const defaultLabel = ownerLabel
|
|
27
|
+
? `Document lock ${active ? "active" : "held"} by ${ownerLabel}`
|
|
28
|
+
: active
|
|
29
|
+
? "Document lock active"
|
|
30
|
+
: "Document locked";
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
{...props}
|
|
35
|
+
aria-label={ariaLabel ?? defaultLabel}
|
|
36
|
+
className={classes}
|
|
37
|
+
data-eth-component="DocumentLockPulse"
|
|
38
|
+
role={role}
|
|
39
|
+
style={style}
|
|
40
|
+
>
|
|
41
|
+
<MotionStyles />
|
|
42
|
+
<span className="eth-motion-document-lock__indicator" aria-hidden="true">
|
|
43
|
+
<span className="eth-motion-document-lock__pulse" />
|
|
44
|
+
<LockGlyph className="eth-motion-document-lock__icon" />
|
|
45
|
+
</span>
|
|
46
|
+
<span className="eth-motion-document-lock__content">
|
|
47
|
+
<span className="eth-motion-document-lock__state">
|
|
48
|
+
{active ? "Active edit" : "Lock held"}
|
|
49
|
+
</span>
|
|
50
|
+
<span className="eth-motion-document-lock__owner">{owner}</span>
|
|
51
|
+
</span>
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function LockGlyph(props: React.SVGProps<SVGSVGElement>) {
|
|
57
|
+
return (
|
|
58
|
+
<svg {...props} aria-hidden="true" focusable="false" viewBox="0 0 16 16">
|
|
59
|
+
<path
|
|
60
|
+
d="M5 7V5a3 3 0 0 1 6 0v2h1a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h1Zm1.5 0h3V5a1.5 1.5 0 0 0-3 0v2Z"
|
|
61
|
+
fill="currentColor"
|
|
62
|
+
/>
|
|
63
|
+
</svg>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function plainText(value: React.ReactNode): string | undefined {
|
|
68
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
69
|
+
return String(value);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { MotionFlowEdge, MotionFlowNode, PipelineFlowAnimationProps } from "./types";
|
|
3
|
+
import { MotionStyles, statusColor, usePrefersReducedMotion } from "./motionUtils";
|
|
4
|
+
|
|
5
|
+
export function PipelineFlowAnimation({
|
|
6
|
+
nodes,
|
|
7
|
+
edges,
|
|
8
|
+
className,
|
|
9
|
+
"aria-label": ariaLabel = "Pipeline flow",
|
|
10
|
+
...props
|
|
11
|
+
}: PipelineFlowAnimationProps) {
|
|
12
|
+
const reduced = usePrefersReducedMotion();
|
|
13
|
+
const positions = layoutNodes(nodes);
|
|
14
|
+
const frame = viewBoxFor(nodes, positions);
|
|
15
|
+
const titleId = React.useId();
|
|
16
|
+
const descriptionId = React.useId();
|
|
17
|
+
const markerBaseId = React.useId();
|
|
18
|
+
const activeNodeIds = new Set(
|
|
19
|
+
edges
|
|
20
|
+
.filter((edge) => edge.active)
|
|
21
|
+
.flatMap((edge) => [edge.from, edge.to])
|
|
22
|
+
.concat(nodes.filter((node) => isActiveStatus(node.status)).map((node) => node.id))
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<section
|
|
27
|
+
{...props}
|
|
28
|
+
className={`eth-motion-pipeline-flow ${reduced ? "eth-motion-reduced" : ""} ${className ?? ""}`}
|
|
29
|
+
data-eth-component="PipelineFlowAnimation"
|
|
30
|
+
aria-label={ariaLabel}
|
|
31
|
+
>
|
|
32
|
+
<MotionStyles />
|
|
33
|
+
<svg
|
|
34
|
+
className="eth-motion-pipeline-flow__svg"
|
|
35
|
+
viewBox={frame.viewBox}
|
|
36
|
+
width={frame.width}
|
|
37
|
+
height={frame.height}
|
|
38
|
+
role="img"
|
|
39
|
+
aria-labelledby={`${titleId} ${descriptionId}`}
|
|
40
|
+
>
|
|
41
|
+
<title id={titleId}>Pipeline flow</title>
|
|
42
|
+
<desc id={descriptionId}>
|
|
43
|
+
{nodes.length
|
|
44
|
+
? `${nodes.length} stages and ${edges.length} handoffs. ${nodes
|
|
45
|
+
.map((node) => `${node.label}: ${labelForStatus(node.status)}`)
|
|
46
|
+
.join(", ")}.`
|
|
47
|
+
: "No pipeline stages."}
|
|
48
|
+
</desc>
|
|
49
|
+
<defs>
|
|
50
|
+
{edges.map((edge, index) => {
|
|
51
|
+
const toNode = nodes.find((node) => node.id === edge.to);
|
|
52
|
+
const color = edgeColor(edge, toNode?.status);
|
|
53
|
+
return (
|
|
54
|
+
<marker
|
|
55
|
+
key={`${edge.from}-${edge.to}-${index}`}
|
|
56
|
+
id={markerIdFromUseId(markerBaseId, index)}
|
|
57
|
+
markerWidth="7"
|
|
58
|
+
markerHeight="7"
|
|
59
|
+
refX="6"
|
|
60
|
+
refY="3.5"
|
|
61
|
+
orient="auto"
|
|
62
|
+
markerUnits="strokeWidth"
|
|
63
|
+
>
|
|
64
|
+
<path d="M0,0 L0,7 L7,3.5 z" fill={color} />
|
|
65
|
+
</marker>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</defs>
|
|
69
|
+
{edges.map((edge, index) => {
|
|
70
|
+
const from = positions.get(edge.from);
|
|
71
|
+
const to = positions.get(edge.to);
|
|
72
|
+
if (!from || !to) return null;
|
|
73
|
+
const toNode = nodes.find((node) => node.id === edge.to);
|
|
74
|
+
const color = edgeColor(edge, toNode?.status);
|
|
75
|
+
const active = Boolean(edge.active);
|
|
76
|
+
return (
|
|
77
|
+
<path
|
|
78
|
+
key={`${edge.from}-${edge.to}-${index}`}
|
|
79
|
+
className="eth-motion-pipeline-flow__edge"
|
|
80
|
+
d={edgePath(from, to)}
|
|
81
|
+
fill="none"
|
|
82
|
+
markerEnd={`url(#${markerIdFromUseId(markerBaseId, index)})`}
|
|
83
|
+
stroke={color}
|
|
84
|
+
strokeWidth={active ? 2.5 : 1.5}
|
|
85
|
+
strokeDasharray={active ? "7 7" : undefined}
|
|
86
|
+
style={
|
|
87
|
+
active && !reduced
|
|
88
|
+
? { animation: "eth-motion-flow-dash 900ms linear infinite" }
|
|
89
|
+
: undefined
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
{!nodes.length ? (
|
|
95
|
+
<text className="eth-motion-pipeline-flow__empty" x="16" y="52">
|
|
96
|
+
No pipeline stages
|
|
97
|
+
</text>
|
|
98
|
+
) : null}
|
|
99
|
+
{nodes.map((node) => {
|
|
100
|
+
const point = positions.get(node.id);
|
|
101
|
+
if (!point) return null;
|
|
102
|
+
const active = activeNodeIds.has(node.id);
|
|
103
|
+
const cardX = -NODE_WIDTH / 2;
|
|
104
|
+
const cardY = -NODE_HEIGHT / 2;
|
|
105
|
+
return (
|
|
106
|
+
<g
|
|
107
|
+
key={node.id}
|
|
108
|
+
className={`eth-motion-pipeline-flow__node ${active ? "eth-motion-pipeline-flow__node--active" : ""}`}
|
|
109
|
+
transform={`translate(${point.x}, ${point.y})`}
|
|
110
|
+
>
|
|
111
|
+
<rect
|
|
112
|
+
className="eth-motion-pipeline-flow__card"
|
|
113
|
+
x={cardX}
|
|
114
|
+
y={cardY}
|
|
115
|
+
width={NODE_WIDTH}
|
|
116
|
+
height={NODE_HEIGHT}
|
|
117
|
+
rx={0}
|
|
118
|
+
/>
|
|
119
|
+
<rect
|
|
120
|
+
className="eth-motion-pipeline-flow__stripe"
|
|
121
|
+
x={cardX}
|
|
122
|
+
y={cardY}
|
|
123
|
+
width={4}
|
|
124
|
+
height={NODE_HEIGHT}
|
|
125
|
+
rx={0}
|
|
126
|
+
fill={statusColor(node.status)}
|
|
127
|
+
/>
|
|
128
|
+
{active && !reduced ? (
|
|
129
|
+
<circle
|
|
130
|
+
className="eth-motion-pipeline-flow__pulse"
|
|
131
|
+
cx={cardX + 22}
|
|
132
|
+
cy={cardY + 20}
|
|
133
|
+
r={9}
|
|
134
|
+
fill="none"
|
|
135
|
+
stroke={statusColor(node.status)}
|
|
136
|
+
/>
|
|
137
|
+
) : null}
|
|
138
|
+
<circle
|
|
139
|
+
className="eth-motion-pipeline-flow__dot"
|
|
140
|
+
cx={cardX + 22}
|
|
141
|
+
cy={cardY + 20}
|
|
142
|
+
r={5}
|
|
143
|
+
fill={statusColor(node.status)}
|
|
144
|
+
/>
|
|
145
|
+
<text className="eth-motion-pipeline-flow__label" x={cardX + 36} y={cardY + 24}>
|
|
146
|
+
{truncate(node.label, 15)}
|
|
147
|
+
</text>
|
|
148
|
+
<text className="eth-motion-pipeline-flow__status" x={cardX + 14} y={cardY + 48}>
|
|
149
|
+
{truncate(labelForStatus(node.status), 20)}
|
|
150
|
+
</text>
|
|
151
|
+
<title>{`${node.label}: ${labelForStatus(node.status)}`}</title>
|
|
152
|
+
</g>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</svg>
|
|
156
|
+
<ol className="eth-motion-pipeline-flow__fallback">
|
|
157
|
+
{nodes.map((node) => (
|
|
158
|
+
<li key={node.id}>
|
|
159
|
+
{node.label}: {labelForStatus(node.status)}
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ol>
|
|
163
|
+
</section>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const NODE_WIDTH = 144;
|
|
168
|
+
const NODE_HEIGHT = 64;
|
|
169
|
+
const NODE_GAP_X = 188;
|
|
170
|
+
|
|
171
|
+
function layoutNodes(nodes: MotionFlowNode[]) {
|
|
172
|
+
const map = new Map<string, { x: number; y: number }>();
|
|
173
|
+
nodes.forEach((node, index) => {
|
|
174
|
+
map.set(node.id, {
|
|
175
|
+
x: node.x ?? NODE_WIDTH / 2 + 20 + NODE_GAP_X * index,
|
|
176
|
+
y: node.y ?? NODE_HEIGHT / 2 + 18
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
return map;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function viewBoxFor(nodes: MotionFlowNode[], positions: Map<string, { x: number; y: number }>) {
|
|
183
|
+
if (!nodes.length) {
|
|
184
|
+
return { viewBox: "0 0 320 96", width: 320, height: 96 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const points = nodes
|
|
188
|
+
.map((node) => positions.get(node.id))
|
|
189
|
+
.filter((point): point is { x: number; y: number } => Boolean(point));
|
|
190
|
+
const minX = Math.floor(Math.min(...points.map((point) => point.x)) - NODE_WIDTH / 2 - 18);
|
|
191
|
+
const minY = Math.floor(Math.min(...points.map((point) => point.y)) - NODE_HEIGHT / 2 - 16);
|
|
192
|
+
const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + NODE_WIDTH / 2 + 26);
|
|
193
|
+
const maxY = Math.ceil(Math.max(...points.map((point) => point.y)) + NODE_HEIGHT / 2 + 16);
|
|
194
|
+
const width = maxX - minX;
|
|
195
|
+
const height = maxY - minY;
|
|
196
|
+
return { viewBox: `${minX} ${minY} ${width} ${height}`, width, height };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function edgePath(from: { x: number; y: number }, to: { x: number; y: number }) {
|
|
200
|
+
const direction = to.x >= from.x ? 1 : -1;
|
|
201
|
+
const start = { x: from.x + (NODE_WIDTH / 2) * direction, y: from.y };
|
|
202
|
+
const end = { x: to.x - (NODE_WIDTH / 2) * direction, y: to.y };
|
|
203
|
+
const controlOffset = Math.max(26, Math.abs(end.x - start.x) * 0.5);
|
|
204
|
+
return `M ${start.x} ${start.y} C ${start.x + controlOffset * direction} ${start.y}, ${end.x - controlOffset * direction} ${end.y}, ${end.x} ${end.y}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function edgeColor(edge: MotionFlowEdge, toStatus?: MotionFlowNode["status"]) {
|
|
208
|
+
if (edge.status) return statusColor(edge.status);
|
|
209
|
+
if (edge.active) return "var(--cds-interactive, #0f62fe)";
|
|
210
|
+
if (toStatus === "failed" || toStatus === "blocked") return statusColor(toStatus);
|
|
211
|
+
return "var(--cds-border-strong, #8d8d8d)";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function markerIdFromUseId(id: string, index: number) {
|
|
215
|
+
return `eth-motion-pipeline-arrow-${id.replace(/[^a-zA-Z0-9_-]/g, "")}-${index}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isActiveStatus(status: MotionFlowNode["status"]) {
|
|
219
|
+
return status === "running" || status === "in-progress" || status === "active";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function labelForStatus(status: MotionFlowNode["status"]) {
|
|
223
|
+
switch (status) {
|
|
224
|
+
case "approval-required":
|
|
225
|
+
return "Approval required";
|
|
226
|
+
case "pending-approval":
|
|
227
|
+
return "Pending approval";
|
|
228
|
+
case "in-progress":
|
|
229
|
+
return "In progress";
|
|
230
|
+
case "not-started":
|
|
231
|
+
return "Not started";
|
|
232
|
+
default:
|
|
233
|
+
return status
|
|
234
|
+
.split("-")
|
|
235
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
236
|
+
.join(" ");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function truncate(value: string, maxLength: number) {
|
|
241
|
+
if (value.length <= maxLength) return value;
|
|
242
|
+
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { ProgressTransitionProps } from "./types";
|
|
3
|
+
import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
|
|
4
|
+
|
|
5
|
+
export function ProgressTransition({
|
|
6
|
+
from,
|
|
7
|
+
to,
|
|
8
|
+
durationMs = 320,
|
|
9
|
+
children,
|
|
10
|
+
className,
|
|
11
|
+
...props
|
|
12
|
+
}: ProgressTransitionProps) {
|
|
13
|
+
const reduced = usePrefersReducedMotion();
|
|
14
|
+
const [current, setCurrent] = React.useState(from ?? to);
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
if (reduced || durationMs <= 0 || typeof window === "undefined") {
|
|
18
|
+
setCurrent(to);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const startValue = from ?? current;
|
|
22
|
+
const delta = to - startValue;
|
|
23
|
+
const startedAt = performance.now();
|
|
24
|
+
let frame = 0;
|
|
25
|
+
const tick = (now: number) => {
|
|
26
|
+
const progress = Math.min(1, (now - startedAt) / durationMs);
|
|
27
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
28
|
+
setCurrent(startValue + delta * eased);
|
|
29
|
+
if (progress < 1) frame = window.requestAnimationFrame(tick);
|
|
30
|
+
};
|
|
31
|
+
frame = window.requestAnimationFrame(tick);
|
|
32
|
+
return () => window.cancelAnimationFrame(frame);
|
|
33
|
+
}, [to, from, durationMs, reduced]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section
|
|
37
|
+
{...props}
|
|
38
|
+
className={`eth-motion-progress-transition ${reduced ? "eth-motion-reduced" : ""} ${className ?? ""}`}
|
|
39
|
+
data-eth-component="ProgressTransition"
|
|
40
|
+
>
|
|
41
|
+
<MotionStyles />
|
|
42
|
+
{children ? (
|
|
43
|
+
children(current)
|
|
44
|
+
) : (
|
|
45
|
+
<progress value={current} max={100} aria-label="Progress">
|
|
46
|
+
{Math.round(current)}%
|
|
47
|
+
</progress>
|
|
48
|
+
)}
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|