@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,248 @@
1
+ import * as React from "react";
2
+ import type { SkeletonLoadingPatternProps, SkeletonLoadingPatternVariant } from "./types";
3
+ import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
4
+
5
+ const articleLineWidths = ["96%", "88%", "100%", "92%", "76%", "84%", "68%", "80%"];
6
+ const listLineWidths = ["76%", "88%", "64%", "82%", "70%", "90%"];
7
+ const tableCellWidths = ["72%", "92%", "58%", "84%", "66%", "78%"];
8
+
9
+ export function SkeletonLoadingPattern({
10
+ columns = 4,
11
+ label = "Loading content",
12
+ lines = 3,
13
+ rows,
14
+ variant = "article",
15
+ className,
16
+ style,
17
+ role = "status",
18
+ "aria-busy": ariaBusy = true,
19
+ "aria-label": ariaLabel,
20
+ ...props
21
+ }: SkeletonLoadingPatternProps) {
22
+ const reduced = usePrefersReducedMotion();
23
+ const pattern = normalizePattern(variant);
24
+ const lineCount = clampCount(lines, 1, 8);
25
+ const rowCount = clampCount(rows ?? (pattern === "table" ? 4 : 3), 1, 8);
26
+ const columnCount = clampCount(columns, 2, 6);
27
+ const classes = [
28
+ "eth-motion-skeleton-loading",
29
+ `eth-motion-skeleton-loading--${pattern}`,
30
+ reduced ? "eth-motion-reduced" : undefined,
31
+ className
32
+ ]
33
+ .filter(Boolean)
34
+ .join(" ");
35
+
36
+ return (
37
+ <section
38
+ {...props}
39
+ aria-busy={ariaBusy}
40
+ aria-label={ariaLabel ?? label}
41
+ className={classes}
42
+ data-eth-component="SkeletonLoadingPattern"
43
+ data-pattern={pattern}
44
+ role={role}
45
+ style={style}
46
+ >
47
+ <MotionStyles />
48
+ {renderPattern(pattern, lineCount, rowCount, columnCount)}
49
+ </section>
50
+ );
51
+ }
52
+
53
+ function renderPattern(
54
+ pattern: SkeletonLoadingPatternVariant,
55
+ lines: number,
56
+ rows: number,
57
+ columns: number
58
+ ) {
59
+ switch (pattern) {
60
+ case "card":
61
+ return <CardPattern lines={lines} />;
62
+ case "list":
63
+ return <ListPattern rows={rows} />;
64
+ case "table":
65
+ return <TablePattern columns={columns} rows={rows} />;
66
+ case "article":
67
+ default:
68
+ return <ArticlePattern lines={lines} />;
69
+ }
70
+ }
71
+
72
+ function ArticlePattern({ lines }: { lines: number }) {
73
+ return (
74
+ <div
75
+ aria-hidden="true"
76
+ className="eth-motion-skeleton-loading__surface eth-motion-skeleton-loading__surface--article"
77
+ >
78
+ <div className="eth-motion-skeleton-loading__article-header">
79
+ <SkeletonBlock
80
+ className="eth-motion-skeleton-loading__block--eyebrow"
81
+ segment="eyebrow"
82
+ style={{ inlineSize: "7rem" }}
83
+ />
84
+ <SkeletonBlock
85
+ className="eth-motion-skeleton-loading__block--title"
86
+ segment="title"
87
+ style={{ inlineSize: "min(22rem, 74%)" }}
88
+ />
89
+ </div>
90
+ <div className="eth-motion-skeleton-loading__article-body">
91
+ {Array.from({ length: lines }).map((_, index) => (
92
+ <SkeletonBlock
93
+ key={index}
94
+ className="eth-motion-skeleton-loading__block--line"
95
+ segment="text-line"
96
+ style={{ inlineSize: articleLineWidths[index % articleLineWidths.length] }}
97
+ />
98
+ ))}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function CardPattern({ lines }: { lines: number }) {
105
+ return (
106
+ <div
107
+ aria-hidden="true"
108
+ className="eth-motion-skeleton-loading__surface eth-motion-skeleton-loading__surface--card"
109
+ >
110
+ <SkeletonBlock className="eth-motion-skeleton-loading__block--media" segment="media" />
111
+ <div className="eth-motion-skeleton-loading__card-body">
112
+ <SkeletonBlock
113
+ className="eth-motion-skeleton-loading__block--title"
114
+ segment="title"
115
+ style={{ inlineSize: "78%" }}
116
+ />
117
+ {Array.from({ length: lines }).map((_, index) => (
118
+ <SkeletonBlock
119
+ key={index}
120
+ className="eth-motion-skeleton-loading__block--line"
121
+ segment="text-line"
122
+ style={{ inlineSize: articleLineWidths[(index + 2) % articleLineWidths.length] }}
123
+ />
124
+ ))}
125
+ </div>
126
+ <div className="eth-motion-skeleton-loading__card-metrics">
127
+ {Array.from({ length: 3 }).map((_, index) => (
128
+ <SkeletonBlock
129
+ key={index}
130
+ className="eth-motion-skeleton-loading__block--metric"
131
+ segment="metric"
132
+ />
133
+ ))}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function ListPattern({ rows }: { rows: number }) {
140
+ return (
141
+ <div
142
+ aria-hidden="true"
143
+ className="eth-motion-skeleton-loading__surface eth-motion-skeleton-loading__surface--list"
144
+ >
145
+ {Array.from({ length: rows }).map((_, index) => (
146
+ <div
147
+ className="eth-motion-skeleton-loading__list-row"
148
+ data-skeleton-row="list"
149
+ key={index}
150
+ >
151
+ <SkeletonBlock className="eth-motion-skeleton-loading__block--avatar" segment="avatar" />
152
+ <div className="eth-motion-skeleton-loading__list-copy">
153
+ <SkeletonBlock
154
+ className="eth-motion-skeleton-loading__block--line"
155
+ segment="text-line"
156
+ style={{ inlineSize: listLineWidths[index % listLineWidths.length] }}
157
+ />
158
+ <SkeletonBlock
159
+ className="eth-motion-skeleton-loading__block--caption"
160
+ segment="caption"
161
+ style={{ inlineSize: listLineWidths[(index + 2) % listLineWidths.length] }}
162
+ />
163
+ </div>
164
+ <SkeletonBlock
165
+ className="eth-motion-skeleton-loading__block--tag"
166
+ segment="tag"
167
+ style={{ inlineSize: index % 2 === 0 ? "4.25rem" : "3.25rem" }}
168
+ />
169
+ </div>
170
+ ))}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ function TablePattern({ columns, rows }: { columns: number; rows: number }) {
176
+ const gridStyle = { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` };
177
+
178
+ return (
179
+ <div
180
+ aria-hidden="true"
181
+ className="eth-motion-skeleton-loading__surface eth-motion-skeleton-loading__surface--table"
182
+ >
183
+ <div className="eth-motion-skeleton-loading__table-header" style={gridStyle}>
184
+ {Array.from({ length: columns }).map((_, index) => (
185
+ <SkeletonBlock
186
+ key={index}
187
+ className="eth-motion-skeleton-loading__block--column"
188
+ segment="column-header"
189
+ style={{ inlineSize: tableCellWidths[(index + 1) % tableCellWidths.length] }}
190
+ />
191
+ ))}
192
+ </div>
193
+ {Array.from({ length: rows }).map((_, rowIndex) => (
194
+ <div
195
+ className="eth-motion-skeleton-loading__table-row"
196
+ data-skeleton-row="table"
197
+ key={rowIndex}
198
+ style={gridStyle}
199
+ >
200
+ {Array.from({ length: columns }).map((_, columnIndex) => (
201
+ <SkeletonBlock
202
+ cell
203
+ key={columnIndex}
204
+ className="eth-motion-skeleton-loading__block--cell"
205
+ segment="cell"
206
+ style={{
207
+ inlineSize: tableCellWidths[(rowIndex + columnIndex) % tableCellWidths.length]
208
+ }}
209
+ />
210
+ ))}
211
+ </div>
212
+ ))}
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function SkeletonBlock({
218
+ cell,
219
+ className,
220
+ segment,
221
+ style
222
+ }: {
223
+ cell?: boolean;
224
+ className?: string;
225
+ segment: string;
226
+ style?: React.CSSProperties;
227
+ }) {
228
+ return (
229
+ <span
230
+ className={`eth-motion-skeleton-loading__block ${className ?? ""}`}
231
+ data-skeleton-cell={cell ? "" : undefined}
232
+ data-skeleton-segment={segment}
233
+ style={style}
234
+ />
235
+ );
236
+ }
237
+
238
+ function clampCount(value: number, min: number, max: number) {
239
+ if (!Number.isFinite(value)) return min;
240
+ return Math.min(max, Math.max(min, Math.floor(value)));
241
+ }
242
+
243
+ function normalizePattern(
244
+ variant: SkeletonLoadingPatternProps["variant"]
245
+ ): SkeletonLoadingPatternVariant {
246
+ if (variant === "card" || variant === "list" || variant === "table") return variant;
247
+ return "article";
248
+ }
@@ -0,0 +1,20 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { StatusChangeAnimation } from "./StatusChangeAnimation";
4
+
5
+ describe("@echothink-ui/motion StatusChangeAnimation", () => {
6
+ it("renders and announces the semantic status transition", () => {
7
+ render(
8
+ <StatusChangeAnimation label="Build pipeline" previousStatus="queued" status="running" />
9
+ );
10
+
11
+ const status = screen.getByRole("status", {
12
+ name: "Build pipeline changed from Queued to Running"
13
+ });
14
+
15
+ expect(status).toBeTruthy();
16
+ expect(screen.getByText("Build pipeline")).toBeTruthy();
17
+ expect(screen.getByText("Queued")).toBeTruthy();
18
+ expect(screen.getByText("Running")).toBeTruthy();
19
+ });
20
+ });
@@ -0,0 +1,89 @@
1
+ import * as React from "react";
2
+ import type { StatusChangeAnimationProps } from "./types";
3
+ import { MotionStyles, statusColor, usePrefersReducedMotion } from "./motionUtils";
4
+
5
+ export function StatusChangeAnimation({
6
+ status,
7
+ previousStatus,
8
+ label,
9
+ className,
10
+ children,
11
+ style,
12
+ "aria-label": ariaLabel,
13
+ "aria-labelledby": ariaLabelledBy,
14
+ ...props
15
+ }: StatusChangeAnimationProps) {
16
+ const reduced = usePrefersReducedMotion();
17
+ const changed = previousStatus !== undefined && previousStatus !== status;
18
+ const statusLabel = formatStatusLabel(status);
19
+ const previousStatusLabel = previousStatus ? formatStatusLabel(previousStatus) : undefined;
20
+ const displayLabel = children ?? label ?? "Status";
21
+ const announcementLabel = textFromNode(label) ?? textFromNode(children) ?? "Status";
22
+ const announcement =
23
+ changed && previousStatusLabel
24
+ ? `${announcementLabel} changed from ${previousStatusLabel} to ${statusLabel}`
25
+ : `${announcementLabel} is ${statusLabel}`;
26
+ const rootClassName = [
27
+ "eth-motion-status-change",
28
+ `eth-motion-status-change--${status}`,
29
+ changed ? "eth-motion-status-change--changed" : null,
30
+ reduced ? "eth-motion-reduced" : null,
31
+ className
32
+ ]
33
+ .filter(Boolean)
34
+ .join(" ");
35
+
36
+ return (
37
+ <span
38
+ {...props}
39
+ aria-atomic="true"
40
+ aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : announcement)}
41
+ aria-labelledby={ariaLabelledBy}
42
+ className={rootClassName}
43
+ data-current-status={status}
44
+ data-eth-component="StatusChangeAnimation"
45
+ data-previous-status={previousStatus}
46
+ role="status"
47
+ style={{
48
+ "--eth-motion-status-color": statusColor(status),
49
+ "--eth-motion-status-previous-color": previousStatus
50
+ ? statusColor(previousStatus)
51
+ : statusColor(status),
52
+ ...style
53
+ } as React.CSSProperties}
54
+ >
55
+ <MotionStyles />
56
+ <span className="eth-motion-status-change__indicator" aria-hidden="true">
57
+ {changed ? <span className="eth-motion-status-change__previous-dot" /> : null}
58
+ {changed ? <span className="eth-motion-status-change__connector" /> : null}
59
+ <span className="eth-motion-status-change__current-dot" />
60
+ </span>
61
+ <span className="eth-motion-status-change__content">
62
+ <span className="eth-motion-status-change__label">{displayLabel}</span>
63
+ <span className="eth-motion-status-change__state">
64
+ {changed && previousStatusLabel ? (
65
+ <>
66
+ <span className="eth-motion-status-change__previous">{previousStatusLabel}</span>
67
+ <span className="eth-motion-status-change__separator">to</span>
68
+ <span className="eth-motion-status-change__current">{statusLabel}</span>
69
+ </>
70
+ ) : (
71
+ <span className="eth-motion-status-change__current">{statusLabel}</span>
72
+ )}
73
+ </span>
74
+ </span>
75
+ </span>
76
+ );
77
+ }
78
+
79
+ function formatStatusLabel(status: string) {
80
+ return status
81
+ .split("-")
82
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
83
+ .join(" ");
84
+ }
85
+
86
+ function textFromNode(node: React.ReactNode) {
87
+ if (typeof node === "string" || typeof node === "number") return String(node);
88
+ return undefined;
89
+ }
@@ -0,0 +1,75 @@
1
+ import * as React from "react";
2
+ import type { StepCompletionAnimationProps } from "./types";
3
+ import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
4
+
5
+ const stateLabels = {
6
+ completed: "Completed",
7
+ current: "In progress",
8
+ pending: "Pending"
9
+ } as const;
10
+
11
+ export function StepCompletionAnimation({
12
+ completed = true,
13
+ description,
14
+ label = "Step completed",
15
+ state,
16
+ className,
17
+ children,
18
+ role = "status",
19
+ "aria-live": ariaLive = "polite",
20
+ ...props
21
+ }: StepCompletionAnimationProps) {
22
+ const reduced = usePrefersReducedMotion();
23
+ const resolvedState = state ?? (completed ? "completed" : "pending");
24
+ const stateLabel = stateLabels[resolvedState];
25
+ const stepClassName = [
26
+ "eth-motion-step-completion",
27
+ `eth-motion-step-completion--${resolvedState}`,
28
+ reduced ? "eth-motion-reduced" : undefined,
29
+ className
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+
34
+ return (
35
+ <span
36
+ {...props}
37
+ aria-live={ariaLive}
38
+ className={stepClassName}
39
+ data-eth-component="StepCompletionAnimation"
40
+ data-state={resolvedState}
41
+ role={role}
42
+ >
43
+ <MotionStyles />
44
+ <span className="eth-motion-step-completion__marker" aria-hidden="true">
45
+ {resolvedState === "completed" ? (
46
+ <svg
47
+ className="eth-motion-step-completion__check"
48
+ focusable="false"
49
+ height="16"
50
+ viewBox="0 0 16 16"
51
+ width="16"
52
+ >
53
+ <path
54
+ className="eth-motion-step-completion__check-path"
55
+ d="M3.5 8.4 6.5 11.4 12.8 4.6"
56
+ />
57
+ </svg>
58
+ ) : (
59
+ <span className="eth-motion-step-completion__dot" />
60
+ )}
61
+ </span>
62
+ <span className="eth-motion-step-completion__content">
63
+ {children ?? (
64
+ <>
65
+ <span className="eth-motion-step-completion__label">{label}</span>
66
+ <span className="eth-motion-step-completion__state">{stateLabel}</span>
67
+ {description ? (
68
+ <span className="eth-motion-step-completion__description">{description}</span>
69
+ ) : null}
70
+ </>
71
+ )}
72
+ </span>
73
+ </span>
74
+ );
75
+ }
@@ -0,0 +1,77 @@
1
+ import * as React from "react";
2
+ import type { StreamingTextProps } from "./types";
3
+ import { MotionStyles, usePrefersReducedMotion } from "./motionUtils";
4
+
5
+ export function StreamingText({
6
+ text,
7
+ intervalMs = 18,
8
+ onDone,
9
+ className,
10
+ role = "status",
11
+ "aria-busy": ariaBusy,
12
+ "aria-live": ariaLive = "polite",
13
+ ...props
14
+ }: StreamingTextProps) {
15
+ const reduced = usePrefersReducedMotion();
16
+ const shouldAnimate = !reduced && intervalMs > 0 && text.length > 0;
17
+ const [visible, setVisible] = React.useState(shouldAnimate ? "" : text);
18
+ const [done, setDone] = React.useState(!shouldAnimate);
19
+ const onDoneRef = React.useRef(onDone);
20
+
21
+ React.useEffect(() => {
22
+ onDoneRef.current = onDone;
23
+ }, [onDone]);
24
+
25
+ React.useEffect(() => {
26
+ if (!shouldAnimate) {
27
+ setVisible(text);
28
+ setDone(true);
29
+ onDoneRef.current?.();
30
+ return;
31
+ }
32
+
33
+ setVisible("");
34
+ setDone(false);
35
+ let index = 0;
36
+ const timer = window.setInterval(() => {
37
+ index += 1;
38
+ setVisible(text.slice(0, index));
39
+ if (index >= text.length) {
40
+ window.clearInterval(timer);
41
+ setDone(true);
42
+ onDoneRef.current?.();
43
+ }
44
+ }, intervalMs);
45
+ return () => window.clearInterval(timer);
46
+ }, [text, intervalMs, shouldAnimate]);
47
+
48
+ const streaming = shouldAnimate && !done;
49
+ const classes = [
50
+ "eth-motion-streaming-text",
51
+ streaming
52
+ ? "eth-motion-streaming-text--streaming"
53
+ : "eth-motion-streaming-text--complete",
54
+ reduced ? "eth-motion-reduced" : undefined,
55
+ className
56
+ ]
57
+ .filter(Boolean)
58
+ .join(" ");
59
+
60
+ return (
61
+ <>
62
+ <MotionStyles />
63
+ <span
64
+ {...props}
65
+ aria-busy={ariaBusy ?? streaming}
66
+ aria-live={ariaLive}
67
+ className={classes}
68
+ data-eth-component="StreamingText"
69
+ data-streaming={streaming ? "true" : "false"}
70
+ role={role}
71
+ >
72
+ <span className="eth-motion-streaming-text__content">{visible}</span>
73
+ <span aria-hidden="true" className="eth-motion-streaming-text__cursor" />
74
+ </span>
75
+ </>
76
+ );
77
+ }
@@ -0,0 +1,49 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SyncProgressAnimation } from "./SyncProgressAnimation";
4
+
5
+ describe("@echothink-ui/motion SyncProgressAnimation", () => {
6
+ it("renders detailed sync progress with replication metadata", () => {
7
+ render(
8
+ <SyncProgressAnimation
9
+ completed={1280}
10
+ label="Syncing mailbox"
11
+ progress={64}
12
+ rate="240 messages/min"
13
+ remaining="2 min"
14
+ source="IMAP primary"
15
+ stage="Applying labels"
16
+ target="Search index"
17
+ total={2000}
18
+ variant="detailed"
19
+ />
20
+ );
21
+
22
+ const status = screen.getByRole("status", {
23
+ name: "Syncing mailbox Syncing, 64% complete, 1280 of 2000 items"
24
+ });
25
+
26
+ expect(status.getAttribute("data-sync-status")).toBe("syncing");
27
+ expect(status.getAttribute("data-sync-progress")).toBe("64");
28
+
29
+ const progressbar = screen.getByRole("progressbar", {
30
+ name: "Sync transfer progress"
31
+ });
32
+
33
+ expect(progressbar.getAttribute("aria-valuenow")).toBe("64");
34
+ expect(screen.getByText("IMAP primary")).toBeTruthy();
35
+ expect(screen.getByText("Search index")).toBeTruthy();
36
+ expect(screen.getByText("Applying labels")).toBeTruthy();
37
+ });
38
+
39
+ it("keeps the default presentation compact for inline usage", () => {
40
+ render(<SyncProgressAnimation label="Syncing mailbox" />);
41
+
42
+ const status = screen.getByRole("status", {
43
+ name: "Syncing mailbox Syncing"
44
+ });
45
+
46
+ expect(status.className).toContain("eth-motion-sync-progress--compact");
47
+ expect(status.querySelector('[role="progressbar"]')).toBeNull();
48
+ });
49
+ });