@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,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
|
+
});
|