@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.
Files changed (37) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AgentThinkingAnimation.d.ts +2 -0
  3. package/dist/components/AttentionPulse.d.ts +2 -0
  4. package/dist/components/DAGStatusTransition.d.ts +2 -0
  5. package/dist/components/DocumentLockPulse.d.ts +2 -0
  6. package/dist/components/PipelineFlowAnimation.d.ts +2 -0
  7. package/dist/components/ProgressTransition.d.ts +2 -0
  8. package/dist/components/SkeletonLoadingPattern.d.ts +2 -0
  9. package/dist/components/StatusChangeAnimation.d.ts +2 -0
  10. package/dist/components/StepCompletionAnimation.d.ts +2 -0
  11. package/dist/components/StreamingText.d.ts +2 -0
  12. package/dist/components/SyncProgressAnimation.d.ts +2 -0
  13. package/dist/components/motionUtils.d.ts +5 -0
  14. package/dist/components/types.d.ts +82 -0
  15. package/dist/index.cjs +2381 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/index.js +2333 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +38 -0
  21. package/src/components/AgentThinkingAnimation.tsx +59 -0
  22. package/src/components/AttentionPulse.tsx +57 -0
  23. package/src/components/DAGStatusTransition.tsx +292 -0
  24. package/src/components/DocumentLockPulse.tsx +72 -0
  25. package/src/components/PipelineFlowAnimation.tsx +243 -0
  26. package/src/components/ProgressTransition.tsx +51 -0
  27. package/src/components/SkeletonLoadingPattern.tsx +248 -0
  28. package/src/components/StatusChangeAnimation.test.tsx +20 -0
  29. package/src/components/StatusChangeAnimation.tsx +89 -0
  30. package/src/components/StepCompletionAnimation.tsx +75 -0
  31. package/src/components/StreamingText.tsx +77 -0
  32. package/src/components/SyncProgressAnimation.test.tsx +49 -0
  33. package/src/components/SyncProgressAnimation.tsx +256 -0
  34. package/src/components/motionUtils.tsx +942 -0
  35. package/src/components/types.ts +111 -0
  36. package/src/index.test.tsx +97 -0
  37. 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
+ }