@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,256 @@
1
+ import * as React from "react";
2
+ import type { SyncProgressAnimationProps, SyncProgressAnimationStatus } from "./types";
3
+ import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
4
+
5
+ const statusContent: Record<
6
+ SyncProgressAnimationStatus,
7
+ { accent: string; description: string; label: string; soft: string }
8
+ > = {
9
+ syncing: {
10
+ accent: "#0f62fe",
11
+ description: "Replicating source updates and applying remote changes.",
12
+ label: "Syncing",
13
+ soft: "#edf5ff"
14
+ },
15
+ synced: {
16
+ accent: "#24a148",
17
+ description: "Replica is current with the source.",
18
+ label: "Synced",
19
+ soft: "#defbe6"
20
+ },
21
+ paused: {
22
+ accent: "#8d8d8d",
23
+ description: "Sync is paused. No changes are being applied.",
24
+ label: "Paused",
25
+ soft: "#f4f4f4"
26
+ },
27
+ stale: {
28
+ accent: "#f1c21b",
29
+ description: "Replica is outside the expected freshness window.",
30
+ label: "Stale",
31
+ soft: "#fcf4d6"
32
+ },
33
+ failed: {
34
+ accent: "#da1e28",
35
+ description: "Sync stopped before completion. Review the connection and retry.",
36
+ label: "Failed",
37
+ soft: "#fff1f1"
38
+ },
39
+ error: {
40
+ accent: "#da1e28",
41
+ description: "Sync stopped before completion. Review the connection and retry.",
42
+ label: "Needs attention",
43
+ soft: "#fff1f1"
44
+ }
45
+ };
46
+
47
+ export function SyncProgressAnimation({
48
+ children,
49
+ className,
50
+ completed,
51
+ description,
52
+ label = "Syncing",
53
+ progress,
54
+ rate,
55
+ remaining,
56
+ role = "status",
57
+ source,
58
+ stage,
59
+ status = "syncing",
60
+ style,
61
+ target,
62
+ total,
63
+ variant,
64
+ "aria-busy": ariaBusy,
65
+ "aria-label": ariaLabel,
66
+ "aria-live": ariaLive,
67
+ ...props
68
+ }: SyncProgressAnimationProps) {
69
+ const reduced = usePrefersReducedMotion();
70
+ const content = statusContent[status];
71
+ const progressValue = normalizeProgress(progress ?? progressFromCounts(completed, total, status));
72
+ const hasProgress = progressValue !== undefined;
73
+ const progressText = hasProgress ? `${progressValue}% complete` : content.label;
74
+ const countText = formatCountProgress(completed, total);
75
+ const descriptionNode = description ?? children ?? content.description;
76
+ const hasDetailedContent = [
77
+ description,
78
+ children,
79
+ source,
80
+ target,
81
+ stage,
82
+ rate,
83
+ remaining,
84
+ completed,
85
+ total,
86
+ progress
87
+ ].some(hasValue);
88
+ const resolvedVariant = variant ?? (hasDetailedContent ? "detailed" : "compact");
89
+ const labelText = textFromNode(label) ?? "Sync";
90
+ const announcement = [content.label, hasProgress ? progressText : undefined, countText]
91
+ .filter(Boolean)
92
+ .join(", ");
93
+ const classes = [
94
+ "eth-motion-sync-progress",
95
+ `eth-motion-sync-progress--${resolvedVariant}`,
96
+ `eth-motion-sync-progress--${status}`,
97
+ reduced ? "eth-motion-reduced" : undefined,
98
+ className
99
+ ]
100
+ .filter(Boolean)
101
+ .join(" ");
102
+ const rootStyle = {
103
+ "--eth-motion-sync-accent": content.accent,
104
+ "--eth-motion-sync-accent-soft": content.soft,
105
+ ...style
106
+ } as React.CSSProperties;
107
+ const busy = ariaBusy ?? (status === "syncing" ? true : undefined);
108
+
109
+ if (resolvedVariant === "compact") {
110
+ return (
111
+ <span
112
+ {...props}
113
+ aria-atomic="true"
114
+ aria-busy={busy}
115
+ aria-label={ariaLabel ?? `${labelText} ${announcement}`}
116
+ aria-live={ariaLive ?? (status === "syncing" ? "polite" : undefined)}
117
+ className={classes}
118
+ data-eth-component="SyncProgressAnimation"
119
+ data-sync-progress={progressValue}
120
+ data-sync-status={status}
121
+ role={role}
122
+ style={rootStyle}
123
+ >
124
+ <MotionStyles />
125
+ <SyncIndicator status={status} />
126
+ <span className="eth-motion-sync-progress__compact-copy">
127
+ <span className="eth-motion-sync-progress__label">{label}</span>
128
+ <span className="eth-motion-sync-progress__compact-state">
129
+ {hasProgress ? `${progressValue}%` : content.label}
130
+ </span>
131
+ </span>
132
+ </span>
133
+ );
134
+ }
135
+
136
+ const metaItems = [
137
+ { label: "Source", value: source },
138
+ { label: "Target", value: target },
139
+ { label: "Rate", value: rate },
140
+ { label: "Remaining", value: remaining }
141
+ ].filter((item) => hasValue(item.value));
142
+
143
+ return (
144
+ <section
145
+ {...props}
146
+ aria-atomic="true"
147
+ aria-busy={busy}
148
+ aria-label={ariaLabel ?? `${labelText} ${announcement}`}
149
+ aria-live={ariaLive ?? (status === "syncing" ? "polite" : undefined)}
150
+ className={classes}
151
+ data-eth-component="SyncProgressAnimation"
152
+ data-sync-progress={progressValue}
153
+ data-sync-status={status}
154
+ role={role}
155
+ style={rootStyle}
156
+ >
157
+ <MotionStyles />
158
+ <SyncIndicator status={status} />
159
+ <div className="eth-motion-sync-progress__body">
160
+ <div className="eth-motion-sync-progress__header">
161
+ <div className="eth-motion-sync-progress__title-group">
162
+ <span className="eth-motion-sync-progress__eyebrow">
163
+ {stage ?? "Replication"}
164
+ </span>
165
+ <span className="eth-motion-sync-progress__label">{label}</span>
166
+ </div>
167
+ <span className="eth-motion-sync-progress__chip">
168
+ <span className="eth-motion-sync-progress__chip-dot" aria-hidden="true" />
169
+ {content.label}
170
+ </span>
171
+ </div>
172
+ <p className="eth-motion-sync-progress__description">{descriptionNode}</p>
173
+ <div className="eth-motion-sync-progress__meter">
174
+ <div
175
+ aria-label="Sync transfer progress"
176
+ aria-valuemax={100}
177
+ aria-valuemin={0}
178
+ aria-valuenow={progressValue}
179
+ aria-valuetext={hasProgress ? progressText : content.label}
180
+ className="eth-motion-sync-progress__track"
181
+ role="progressbar"
182
+ >
183
+ <span
184
+ className={`eth-motion-sync-progress__bar ${
185
+ hasProgress ? "" : "eth-motion-sync-progress__bar--indeterminate"
186
+ }`}
187
+ style={hasProgress ? { inlineSize: `${progressValue}%` } : undefined}
188
+ />
189
+ </div>
190
+ <span className="eth-motion-sync-progress__progress-copy">
191
+ {[hasProgress ? `${progressValue}%` : content.label, countText]
192
+ .filter(Boolean)
193
+ .join(" | ")}
194
+ </span>
195
+ </div>
196
+ </div>
197
+ {metaItems.length ? (
198
+ <dl className="eth-motion-sync-progress__meta" aria-label="Sync metadata">
199
+ {metaItems.map((item) => (
200
+ <div className="eth-motion-sync-progress__meta-item" key={item.label}>
201
+ <dt>{item.label}</dt>
202
+ <dd>{item.value}</dd>
203
+ </div>
204
+ ))}
205
+ </dl>
206
+ ) : null}
207
+ </section>
208
+ );
209
+ }
210
+
211
+ function SyncIndicator({ status }: { status: SyncProgressAnimationStatus }) {
212
+ return (
213
+ <span className="eth-motion-sync-progress__indicator" aria-hidden="true">
214
+ {status === "syncing" ? (
215
+ <span className="eth-motion-sync-progress__spinner" />
216
+ ) : (
217
+ <span className="eth-motion-sync-progress__glyph" />
218
+ )}
219
+ </span>
220
+ );
221
+ }
222
+
223
+ function progressFromCounts(
224
+ completed: number | undefined,
225
+ total: number | undefined,
226
+ status: SyncProgressAnimationStatus
227
+ ) {
228
+ if (status === "synced") return 100;
229
+ if (!isFiniteNumber(completed) || !isFiniteNumber(total) || total <= 0) return undefined;
230
+ return (completed / total) * 100;
231
+ }
232
+
233
+ function normalizeProgress(value: number | undefined) {
234
+ if (!isFiniteNumber(value)) return undefined;
235
+ return Math.min(100, Math.max(0, Math.round(value)));
236
+ }
237
+
238
+ function formatCountProgress(completed: number | undefined, total: number | undefined) {
239
+ if (!isFiniteNumber(completed) || !isFiniteNumber(total) || total <= 0) return undefined;
240
+ const safeCompleted = Math.max(0, Math.floor(completed));
241
+ const safeTotal = Math.max(0, Math.floor(total));
242
+ return `${safeCompleted} of ${safeTotal} items`;
243
+ }
244
+
245
+ function isFiniteNumber(value: unknown): value is number {
246
+ return typeof value === "number" && Number.isFinite(value);
247
+ }
248
+
249
+ function hasValue(value: unknown) {
250
+ return value !== undefined && value !== null && value !== false && value !== "";
251
+ }
252
+
253
+ function textFromNode(node: React.ReactNode) {
254
+ if (typeof node === "string" || typeof node === "number") return String(node);
255
+ return undefined;
256
+ }