@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { GroupedParts } from "./grouped-parts";
|
|
3
|
+
import { groupPartByType, type GroupablePart } from "./part-groups";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "AI/GroupedParts",
|
|
7
|
+
component: GroupedParts,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: { layout: "padded" },
|
|
10
|
+
} satisfies Meta<typeof GroupedParts>;
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
// A representative ordered part list, the shape an AI SDK UIMessage exposes.
|
|
15
|
+
const parts: GroupablePart[] = [
|
|
16
|
+
{
|
|
17
|
+
type: "reasoning",
|
|
18
|
+
state: "done",
|
|
19
|
+
text: "The user wants Q3 revenue by region. I'll query the warehouse and reconcile.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "tool-query_warehouse",
|
|
23
|
+
state: "output-available",
|
|
24
|
+
input: { sql: "select region, sum(amount) from sales group by region" },
|
|
25
|
+
output: { rows: 4 },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: "tool-reconcile",
|
|
29
|
+
state: "output-available",
|
|
30
|
+
input: { source: "ledger" },
|
|
31
|
+
output: { reconciled: 8 },
|
|
32
|
+
},
|
|
33
|
+
{ type: "text", text: "Q3 revenue totalled $4.2M across 4 regions — EMEA led at $1.8M." },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Default — the reasoning + tool run folds into one collapsible trace; the
|
|
37
|
+
* final answer stays a top-level leaf. */
|
|
38
|
+
export const Default: Story = {
|
|
39
|
+
args: { parts },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Streaming — a tool is still running, so the trace rolls up to "running" and
|
|
43
|
+
* opens automatically. */
|
|
44
|
+
export const Streaming: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
parts: [
|
|
47
|
+
{ type: "reasoning", state: "streaming", text: "Deciding which tables to join…" },
|
|
48
|
+
{ type: "tool-query_warehouse", state: "input-available", input: { sql: "select …" } },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Interaction cards never fold — an approval-style part is forced standalone,
|
|
54
|
+
* splitting the trace around it. */
|
|
55
|
+
export const StandaloneInteraction: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
parts: [
|
|
58
|
+
{ type: "reasoning", state: "done", text: "This will delete 12 records." },
|
|
59
|
+
{ type: "approval-request", state: "approval-requested" },
|
|
60
|
+
{ type: "reasoning", state: "done", text: "Approved — proceeding." },
|
|
61
|
+
{ type: "text", text: "Done — 12 records removed." },
|
|
62
|
+
],
|
|
63
|
+
children: ({ part, isGroup, children }) =>
|
|
64
|
+
isGroup ? (
|
|
65
|
+
<>{children}</>
|
|
66
|
+
) : part.type === "approval-request" ? (
|
|
67
|
+
<div className="rounded-md border border-border-strong border-s-4 border-s-border-strong bg-card p-3 text-body">
|
|
68
|
+
<p className="font-medium text-foreground">Approve deletion of 12 records?</p>
|
|
69
|
+
</div>
|
|
70
|
+
) : part.type === "text" ? (
|
|
71
|
+
<p className="text-body text-foreground">
|
|
72
|
+
{String((part as { text?: string }).text ?? "")}
|
|
73
|
+
</p>
|
|
74
|
+
) : undefined,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Custom grouping — group everything into a single trace via a custom groupBy. */
|
|
79
|
+
export const CustomGrouping: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
parts,
|
|
82
|
+
groupBy: groupPartByType<GroupablePart>({
|
|
83
|
+
groupKey: "group-analysis",
|
|
84
|
+
inline: [/^reasoning$/, /^tool-/, "text"],
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Empty — an empty part list renders nothing (no broken UI). */
|
|
90
|
+
export const Empty: Story = {
|
|
91
|
+
args: { parts: [] },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Dark theme — trace disclosure + tool cards in dark. */
|
|
95
|
+
export const DarkTheme: Story = {
|
|
96
|
+
args: { parts },
|
|
97
|
+
decorators: [
|
|
98
|
+
(Story) => (
|
|
99
|
+
<div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
|
|
100
|
+
<Story />
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { GroupedParts } from "./grouped-parts";
|
|
5
|
+
import {
|
|
6
|
+
buildPartGroups,
|
|
7
|
+
groupPartByType,
|
|
8
|
+
isGroupNode,
|
|
9
|
+
partsFingerprint,
|
|
10
|
+
type GroupablePart,
|
|
11
|
+
type GroupBy,
|
|
12
|
+
} from "./part-groups";
|
|
13
|
+
|
|
14
|
+
interface P extends GroupablePart {
|
|
15
|
+
type: string;
|
|
16
|
+
state?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("part-groups — coalescing", () => {
|
|
22
|
+
it("coalesces an adjacent reasoning+tool run into one group, leaves text alone", () => {
|
|
23
|
+
const parts: P[] = [
|
|
24
|
+
{ type: "text", text: "Hi" },
|
|
25
|
+
{ type: "reasoning", state: "done" },
|
|
26
|
+
{ type: "tool-search", state: "output-available" },
|
|
27
|
+
{ type: "text", text: "Done" },
|
|
28
|
+
];
|
|
29
|
+
const tree = buildPartGroups(parts, groupPartByType<P>());
|
|
30
|
+
expect(tree).toHaveLength(3);
|
|
31
|
+
expect(tree[0]).toMatchObject({ kind: "leaf", part: { type: "text" } });
|
|
32
|
+
const group = tree[1];
|
|
33
|
+
expect(group).toMatchObject({ kind: "group" });
|
|
34
|
+
if (group?.kind === "group") {
|
|
35
|
+
expect(group.group.type).toBe("group-work");
|
|
36
|
+
expect(group.group.indices).toEqual([1, 2]);
|
|
37
|
+
expect(group.children).toHaveLength(2);
|
|
38
|
+
}
|
|
39
|
+
expect(tree[2]).toMatchObject({ kind: "leaf", part: { type: "text" } });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does NOT merge two groups separated by a standalone part", () => {
|
|
43
|
+
const parts: P[] = [
|
|
44
|
+
{ type: "reasoning", state: "done" },
|
|
45
|
+
{ type: "approval-request" },
|
|
46
|
+
{ type: "reasoning", state: "done" },
|
|
47
|
+
];
|
|
48
|
+
const tree = buildPartGroups(parts, groupPartByType<P>());
|
|
49
|
+
expect(tree).toHaveLength(3);
|
|
50
|
+
expect(tree[1]).toMatchObject({ kind: "leaf", part: { type: "approval-request" } });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("nests multi-element paths", () => {
|
|
54
|
+
const groupBy: GroupBy<P> = (p) =>
|
|
55
|
+
p.type === "a" ? ["group-outer", "group-inner"] : p.type === "b" ? ["group-outer"] : [];
|
|
56
|
+
const parts: P[] = [{ type: "a" }, { type: "a" }, { type: "b" }];
|
|
57
|
+
const tree = buildPartGroups(parts, groupBy);
|
|
58
|
+
expect(tree).toHaveLength(1);
|
|
59
|
+
const outer = tree[0];
|
|
60
|
+
expect(outer).toMatchObject({ kind: "group", group: { type: "group-outer" } });
|
|
61
|
+
if (outer?.kind === "group") {
|
|
62
|
+
expect(outer.children).toHaveLength(2);
|
|
63
|
+
expect(outer.children[0]).toMatchObject({ kind: "group", group: { type: "group-inner" } });
|
|
64
|
+
expect(outer.children[1]).toMatchObject({ kind: "leaf", part: { type: "b" } });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("part-groups — status rollup + identity", () => {
|
|
70
|
+
it("rolls up to running when any member is running", () => {
|
|
71
|
+
const parts: P[] = [
|
|
72
|
+
{ type: "reasoning", state: "streaming" },
|
|
73
|
+
{ type: "tool-x", state: "output-available" },
|
|
74
|
+
];
|
|
75
|
+
const tree = buildPartGroups(parts, groupPartByType<P>());
|
|
76
|
+
expect(tree[0]).toMatchObject({ kind: "group", group: { status: "running" } });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses a part id for a stable leaf key across re-renders", () => {
|
|
80
|
+
const before = buildPartGroups([{ type: "text", id: "abc" }] as P[], groupPartByType<P>());
|
|
81
|
+
const after = buildPartGroups(
|
|
82
|
+
[{ type: "text", id: "abc", text: "grown" }] as P[],
|
|
83
|
+
groupPartByType<P>(),
|
|
84
|
+
);
|
|
85
|
+
expect(before[0]!.key).toBe(after[0]!.key);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("fingerprint changes on status change but not on unrelated identity churn", () => {
|
|
89
|
+
const a = partsFingerprint([{ type: "tool-x", state: "input-available", id: "1" }] as P[]);
|
|
90
|
+
const b = partsFingerprint([{ type: "tool-x", state: "output-available", id: "1" }] as P[]);
|
|
91
|
+
expect(a).not.toBe(b);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("groupPartByType — classification", () => {
|
|
96
|
+
it("honors an explicit inline classifier", () => {
|
|
97
|
+
const gb = groupPartByType<P>({ classify: (p) => (p.type === "text" ? "inline" : undefined) });
|
|
98
|
+
const tree = buildPartGroups([{ type: "text" }, { type: "text" }] as P[], gb);
|
|
99
|
+
expect(tree).toHaveLength(1);
|
|
100
|
+
expect(tree[0]).toMatchObject({ kind: "group" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("forces standalone parts out of a group even when classified inline elsewhere", () => {
|
|
104
|
+
const gb = groupPartByType<P>();
|
|
105
|
+
expect(gb({ type: "human-input" } as P, { index: 0, parts: [] })).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("isGroupNode narrows synthetic nodes", () => {
|
|
109
|
+
expect(isGroupNode({ type: "group-work" })).toBe(true);
|
|
110
|
+
expect(isGroupNode({ type: "reasoning" })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("GroupedParts — component", () => {
|
|
115
|
+
it("renders a default open trace for a running group", () => {
|
|
116
|
+
render(
|
|
117
|
+
<GroupedParts
|
|
118
|
+
parts={[
|
|
119
|
+
{ type: "reasoning", state: "streaming", text: "thinking" },
|
|
120
|
+
{ type: "tool-search", state: "input-available", input: {} },
|
|
121
|
+
]}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
// Running → the trace label + the tool header inside are visible.
|
|
125
|
+
expect(screen.getByText("Working…")).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByText("search")).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the ToolOutput skeleton for an in-flight tool part (#269/#8 — no output area gap)", async () => {
|
|
130
|
+
const user = userEvent.setup();
|
|
131
|
+
render(
|
|
132
|
+
<GroupedParts
|
|
133
|
+
parts={[{ type: "tool-search", state: "input-streaming", input: {}, id: "call-1" }]}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
// The outer trace opens by default (running), but the individual Tool's
|
|
137
|
+
// own disclosure still defaults closed — expand it to reach ToolContent.
|
|
138
|
+
await user.click(screen.getByText("search"));
|
|
139
|
+
// Previously the output area was gated behind `output !== undefined ||
|
|
140
|
+
// errorText`, so an `input-streaming` tool rendered NOTHING here. Now it
|
|
141
|
+
// shows the settled "Result" heading + a loading live region instead of a
|
|
142
|
+
// gap.
|
|
143
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
144
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("passes group nodes (with children) and leaves to the render-prop", () => {
|
|
148
|
+
render(
|
|
149
|
+
<GroupedParts parts={[{ type: "reasoning", state: "done" }, { type: "approval-request" }]}>
|
|
150
|
+
{({ part, isGroup, children }) =>
|
|
151
|
+
isGroup ? (
|
|
152
|
+
<div data-testid="group">
|
|
153
|
+
group:{part.type}
|
|
154
|
+
{children}
|
|
155
|
+
</div>
|
|
156
|
+
) : (
|
|
157
|
+
<div data-testid={`leaf-${part.type}`}>leaf:{part.type}</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
</GroupedParts>,
|
|
161
|
+
);
|
|
162
|
+
expect(screen.getByTestId("group")).toHaveTextContent("group:group-work");
|
|
163
|
+
// The reasoning leaf renders INSIDE the group; the approval is a standalone leaf.
|
|
164
|
+
expect(screen.getByTestId("group")).toContainElement(screen.getByTestId("leaf-reasoning"));
|
|
165
|
+
expect(screen.getByTestId("leaf-approval-request")).toHaveTextContent("leaf:approval-request");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GroupedParts — renders an ordered part list, folding adjacent reasoning/tool
|
|
5
|
+
* parts into collapsible traces (see `part-groups.ts` for the pure engine).
|
|
6
|
+
*
|
|
7
|
+
* A render-prop switches on `part.type`. Group nodes arrive as synthetic
|
|
8
|
+
* `{ type: "group-…", status, indices }` with their rendered `children`; real
|
|
9
|
+
* parts arrive as leaves. When no render-prop is given, sensible defaults
|
|
10
|
+
* integrate the existing `Reasoning` / `Tool` components (and a collapsible
|
|
11
|
+
* trace for groups). It is presentational only — the grouping is a client-side
|
|
12
|
+
* view transform, never a model output format.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: this is a generic function component (not `forwardRef`) because the
|
|
15
|
+
* render-prop is generic over the part type `T`; `forwardRef` erases generics.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Fragment, useMemo, type ComponentProps, type HTMLAttributes, type ReactNode } from "react";
|
|
19
|
+
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
|
20
|
+
import {
|
|
21
|
+
Badge,
|
|
22
|
+
Collapsible,
|
|
23
|
+
CollapsibleContent,
|
|
24
|
+
CollapsibleTrigger,
|
|
25
|
+
} from "@elabs-ai/components-ui";
|
|
26
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
27
|
+
|
|
28
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning";
|
|
29
|
+
import {
|
|
30
|
+
Tool,
|
|
31
|
+
ToolContent,
|
|
32
|
+
ToolHeader,
|
|
33
|
+
ToolInput,
|
|
34
|
+
ToolOutput,
|
|
35
|
+
statusFromToolState,
|
|
36
|
+
type ToolPart,
|
|
37
|
+
} from "./tool";
|
|
38
|
+
import {
|
|
39
|
+
buildPartGroups,
|
|
40
|
+
defaultPartStatus,
|
|
41
|
+
groupPartByType,
|
|
42
|
+
partsFingerprint,
|
|
43
|
+
type GetPartStatus,
|
|
44
|
+
type GroupablePart,
|
|
45
|
+
type GroupBy,
|
|
46
|
+
type GroupedNode,
|
|
47
|
+
type GroupStatus,
|
|
48
|
+
type PartGroupNode,
|
|
49
|
+
} from "./part-groups";
|
|
50
|
+
|
|
51
|
+
// ─── Render-prop args ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface GroupedPartRenderArgs<T extends GroupablePart> {
|
|
54
|
+
/** The real part (leaf) or the synthetic group node. */
|
|
55
|
+
part: T | PartGroupNode;
|
|
56
|
+
/** True when `part` is a synthetic group node. */
|
|
57
|
+
isGroup: boolean;
|
|
58
|
+
/** The rendered subtree — group nodes only. */
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
/** Rolled-up status (groups) or the part's status (leaves). */
|
|
61
|
+
status: GroupStatus;
|
|
62
|
+
/** Original part indices under this group (groups only). */
|
|
63
|
+
indices?: number[];
|
|
64
|
+
/** Original part index (leaves only). */
|
|
65
|
+
index?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type GroupedPartRenderer<T extends GroupablePart> = (
|
|
69
|
+
args: GroupedPartRenderArgs<T>,
|
|
70
|
+
) => ReactNode;
|
|
71
|
+
|
|
72
|
+
// ─── Default renderers ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function DefaultGroup({
|
|
75
|
+
status,
|
|
76
|
+
count,
|
|
77
|
+
children,
|
|
78
|
+
}: {
|
|
79
|
+
status: GroupStatus;
|
|
80
|
+
count: number;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
}) {
|
|
83
|
+
const running = status === "running";
|
|
84
|
+
return (
|
|
85
|
+
<Collapsible defaultOpen={running} className="not-prose w-full rounded-md border border-border">
|
|
86
|
+
<CollapsibleTrigger
|
|
87
|
+
className={cn(
|
|
88
|
+
"group flex w-full items-center gap-2 p-3 text-body text-muted-foreground",
|
|
89
|
+
"transition-colors duration-fast ease-standard hover:text-foreground motion-reduce:transition-none",
|
|
90
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<BrainIcon aria-hidden="true" className="size-4 shrink-0" />
|
|
94
|
+
<span className="flex-1 text-start">{running ? "Working…" : "Thought process"}</span>
|
|
95
|
+
{count > 0 && (
|
|
96
|
+
<Badge variant="secondary" className="shrink-0">
|
|
97
|
+
{count}
|
|
98
|
+
</Badge>
|
|
99
|
+
)}
|
|
100
|
+
<ChevronDownIcon
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180 motion-reduce:transition-none"
|
|
103
|
+
/>
|
|
104
|
+
</CollapsibleTrigger>
|
|
105
|
+
<CollapsibleContent className="space-y-3 p-3 pt-0">{children}</CollapsibleContent>
|
|
106
|
+
</Collapsible>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultRenderPart<T extends GroupablePart>(args: GroupedPartRenderArgs<T>): ReactNode {
|
|
111
|
+
if (args.isGroup) {
|
|
112
|
+
return (
|
|
113
|
+
<DefaultGroup status={args.status} count={args.indices?.length ?? 0}>
|
|
114
|
+
{args.children}
|
|
115
|
+
</DefaultGroup>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const part = args.part as GroupablePart;
|
|
120
|
+
const streaming = args.status === "running";
|
|
121
|
+
|
|
122
|
+
// Reasoning → the branded Reasoning disclosure.
|
|
123
|
+
if (part.type === "reasoning" || part.type.startsWith("reasoning")) {
|
|
124
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
125
|
+
return (
|
|
126
|
+
<Reasoning isStreaming={streaming} defaultOpen={streaming}>
|
|
127
|
+
<ReasoningTrigger />
|
|
128
|
+
<ReasoningContent>{text}</ReasoningContent>
|
|
129
|
+
</Reasoning>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Tool call → the branded Tool disclosure (defensive: parts are plain objects).
|
|
134
|
+
if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
|
|
135
|
+
const p = part as Record<string, unknown>;
|
|
136
|
+
const headerProps = {
|
|
137
|
+
type: p.type,
|
|
138
|
+
state: p.state ?? "input-available",
|
|
139
|
+
toolName: p.type === "dynamic-tool" ? p.toolName : undefined,
|
|
140
|
+
} as ComponentProps<typeof ToolHeader>;
|
|
141
|
+
// The output area must reflect an in-flight call, not just a settled one
|
|
142
|
+
// (loading-states.md / #269): a `tool-*` part in `input-streaming` (or
|
|
143
|
+
// `input-available`, before output/error land) is "pending", so
|
|
144
|
+
// `ToolOutput` renders its skeleton instead of nothing.
|
|
145
|
+
const toolStreaming =
|
|
146
|
+
statusFromToolState((p.state as ToolPart["state"]) ?? "input-available") === "pending";
|
|
147
|
+
return (
|
|
148
|
+
<Tool>
|
|
149
|
+
<ToolHeader {...headerProps} />
|
|
150
|
+
<ToolContent>
|
|
151
|
+
{p.input !== undefined && <ToolInput input={p.input} />}
|
|
152
|
+
{(toolStreaming || p.output !== undefined || Boolean(p.errorText)) && (
|
|
153
|
+
<ToolOutput
|
|
154
|
+
output={p.output}
|
|
155
|
+
errorText={(p.errorText as string) ?? undefined}
|
|
156
|
+
isStreaming={toolStreaming}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</ToolContent>
|
|
160
|
+
</Tool>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Text → plain prose (consumers usually override with a markdown renderer).
|
|
165
|
+
if (part.type === "text") {
|
|
166
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
167
|
+
return <div className="whitespace-pre-wrap text-body text-foreground">{text}</div>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Unknown leaf types: nothing by default (supply a render-prop for coverage).
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── GroupedParts ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export interface GroupedPartsProps<T extends GroupablePart> extends Omit<
|
|
177
|
+
HTMLAttributes<HTMLDivElement>,
|
|
178
|
+
"children"
|
|
179
|
+
> {
|
|
180
|
+
/** The ordered message parts to render. */
|
|
181
|
+
parts: readonly T[];
|
|
182
|
+
/** How to group parts. @default `groupPartByType()` */
|
|
183
|
+
groupBy?: GroupBy<T>;
|
|
184
|
+
/** Extract a part's status (drives group rollup + streaming affordances). */
|
|
185
|
+
getStatus?: GetPartStatus<T>;
|
|
186
|
+
/** Render-prop switching on `part.type`. Omit to use the default renderers. */
|
|
187
|
+
children?: GroupedPartRenderer<T>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Renders `parts`, coalescing adjacent grouped parts into collapsible traces.
|
|
192
|
+
* The tree only rebuilds when the parts' identity/type/status fingerprint
|
|
193
|
+
* changes, so streaming token updates don't remount rows.
|
|
194
|
+
*/
|
|
195
|
+
export function GroupedParts<T extends GroupablePart>({
|
|
196
|
+
parts,
|
|
197
|
+
groupBy = groupPartByType<T>(),
|
|
198
|
+
getStatus,
|
|
199
|
+
children,
|
|
200
|
+
className,
|
|
201
|
+
...props
|
|
202
|
+
}: GroupedPartsProps<T>) {
|
|
203
|
+
const fingerprint = partsFingerprint(parts, getStatus);
|
|
204
|
+
const tree = useMemo<GroupedNode<T>[]>(
|
|
205
|
+
() => buildPartGroups(parts, groupBy, getStatus),
|
|
206
|
+
// Rebuild only on a structural change; `parts`/`groupBy`/`getStatus` identity
|
|
207
|
+
// may churn per render, but the fingerprint is the real dependency.
|
|
208
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
209
|
+
[fingerprint, groupBy, getStatus],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const renderPart = children ?? defaultRenderPart<T>;
|
|
213
|
+
const statusOf = (part: T): GroupStatus => getStatus?.(part) ?? defaultPartStatus(part);
|
|
214
|
+
|
|
215
|
+
const renderNode = (node: GroupedNode<T>): ReactNode => {
|
|
216
|
+
if (node.kind === "leaf") {
|
|
217
|
+
return (
|
|
218
|
+
<Fragment key={node.key}>
|
|
219
|
+
{renderPart({
|
|
220
|
+
part: node.part,
|
|
221
|
+
isGroup: false,
|
|
222
|
+
status: statusOf(node.part),
|
|
223
|
+
index: node.index,
|
|
224
|
+
})}
|
|
225
|
+
</Fragment>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const kids = node.children.map(renderNode);
|
|
229
|
+
return (
|
|
230
|
+
<Fragment key={node.key}>
|
|
231
|
+
{renderPart({
|
|
232
|
+
part: node.group,
|
|
233
|
+
isGroup: true,
|
|
234
|
+
status: node.group.status,
|
|
235
|
+
indices: node.group.indices,
|
|
236
|
+
children: <>{kids}</>,
|
|
237
|
+
})}
|
|
238
|
+
</Fragment>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div className={cn("flex w-full flex-col gap-3", className)} {...props}>
|
|
244
|
+
{tree.map(renderNode)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Image } from "./image";
|
|
3
|
+
|
|
4
|
+
// A tiny 1x1 solid-red PNG — enough to exercise decode + skeleton timing
|
|
5
|
+
// without shipping a real asset in the story.
|
|
6
|
+
const RED_DOT =
|
|
7
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: "AI/Image",
|
|
11
|
+
component: Image,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
} satisfies Meta<typeof Image>;
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
// `showSkeleton` (default `Boolean(width && height)` — true here, since both
|
|
18
|
+
// are given) covers the box with a `Skeleton` until the `<img>` finishes
|
|
19
|
+
// decoding, reserving the `width`/`height` box so there is no layout shift
|
|
20
|
+
// (loading-states.md, interaction-guidelines.md 'Images'). Without both
|
|
21
|
+
// dimensions there is no box to reserve, so the skeleton defaults off in that
|
|
22
|
+
// case rather than collapsing to nothing (see the `NoSkeleton` story). The
|
|
23
|
+
// Skeleton window is real-decode-timed (there is no consumer `loading` prop
|
|
24
|
+
// like Gallery's — see image.tsx), so it is too fast/racy to freeze reliably
|
|
25
|
+
// in a real-browser Storybook snapshot; it is instead exercised
|
|
26
|
+
// deterministically in image.test.tsx (jsdom never auto-resolves `<img>`
|
|
27
|
+
// loads, so the pending state holds until the test fires `load` itself).
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
base64: RED_DOT,
|
|
31
|
+
mediaType: "image/png",
|
|
32
|
+
uint8Array: new Uint8Array(),
|
|
33
|
+
alt: "A generated red square",
|
|
34
|
+
width: 128,
|
|
35
|
+
height: 128,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// BROKEN — a genuinely undecodable payload settles into the `onError`
|
|
40
|
+
// fallback (an `ImageOff` glyph on a muted box, sized to `width`/`height`)
|
|
41
|
+
// instead of leaving the browser's native broken-image glyph bleeding through
|
|
42
|
+
// a perpetually-pulsing Skeleton.
|
|
43
|
+
export const Broken: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
base64: "not-a-real-image",
|
|
46
|
+
mediaType: "image/png",
|
|
47
|
+
uint8Array: new Uint8Array(),
|
|
48
|
+
alt: "A generated image",
|
|
49
|
+
width: 128,
|
|
50
|
+
height: 128,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Disabling `showSkeleton` opts out for a context that already has its own
|
|
55
|
+
// placeholder (e.g. a Gallery tile).
|
|
56
|
+
export const NoSkeleton: Story = {
|
|
57
|
+
name: "showSkeleton=false",
|
|
58
|
+
args: {
|
|
59
|
+
base64: RED_DOT,
|
|
60
|
+
mediaType: "image/png",
|
|
61
|
+
uint8Array: new Uint8Array(),
|
|
62
|
+
alt: "A generated red square",
|
|
63
|
+
width: 128,
|
|
64
|
+
height: 128,
|
|
65
|
+
showSkeleton: false,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { createRef } from "react";
|
|
4
|
+
import { Image, type ImageProps } from "./image";
|
|
5
|
+
|
|
6
|
+
const RED_DOT =
|
|
7
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
|
|
8
|
+
|
|
9
|
+
const BASE_PROPS: Pick<ImageProps, "base64" | "mediaType" | "uint8Array"> = {
|
|
10
|
+
base64: RED_DOT,
|
|
11
|
+
mediaType: "image/png",
|
|
12
|
+
uint8Array: new Uint8Array(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("Image", () => {
|
|
16
|
+
it("renders the img with the base64 data URI, alt, width and height", () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<Image {...BASE_PROPS} alt="A red square" width={64} height={64} />,
|
|
19
|
+
);
|
|
20
|
+
const img = container.querySelector("img");
|
|
21
|
+
expect(img).not.toBeNull();
|
|
22
|
+
expect(img?.getAttribute("src")).toBe(`data:image/png;base64,${RED_DOT}`);
|
|
23
|
+
expect(img?.getAttribute("alt")).toBe("A red square");
|
|
24
|
+
expect(img?.getAttribute("width")).toBe("64");
|
|
25
|
+
expect(img?.getAttribute("height")).toBe("64");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("defaults alt to empty (decorative) rather than omitting it", () => {
|
|
29
|
+
const { container } = render(<Image {...BASE_PROPS} />);
|
|
30
|
+
expect(container.querySelector("img")?.getAttribute("alt")).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("forwards a ref to the underlying <img>", () => {
|
|
34
|
+
const ref = createRef<HTMLImageElement>();
|
|
35
|
+
render(<Image ref={ref} {...BASE_PROPS} />);
|
|
36
|
+
expect(ref.current).toBeInstanceOf(HTMLImageElement);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Image showSkeleton (#269, loading-states.md)", () => {
|
|
41
|
+
it("shows a Skeleton (decorative + a live region) until onLoad fires, when width+height are given", () => {
|
|
42
|
+
const { container } = render(<Image {...BASE_PROPS} alt="" width={64} height={64} />);
|
|
43
|
+
const skeleton = container.querySelector(".animate-pulse");
|
|
44
|
+
expect(skeleton).not.toBeNull();
|
|
45
|
+
expect(skeleton).toHaveAttribute("aria-hidden", "true");
|
|
46
|
+
// Exactly one live region for the region.
|
|
47
|
+
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
|
|
48
|
+
|
|
49
|
+
const img = container.querySelector("img")!;
|
|
50
|
+
fireEvent.load(img);
|
|
51
|
+
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("defaults to no skeleton (and no live region) when width/height are not given — nothing to reserve", () => {
|
|
55
|
+
const { container } = render(<Image {...BASE_PROPS} alt="" />);
|
|
56
|
+
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
57
|
+
expect(container.querySelector('[role="status"]')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("skips the skeleton entirely when showSkeleton is explicitly false, even with width+height", () => {
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<Image {...BASE_PROPS} alt="" width={64} height={64} showSkeleton={false} />,
|
|
63
|
+
);
|
|
64
|
+
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
65
|
+
expect(container.querySelector('[role="status"]')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows the skeleton when explicitly enabled even without width/height", () => {
|
|
69
|
+
const { container } = render(<Image {...BASE_PROPS} alt="" showSkeleton />);
|
|
70
|
+
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not wrap the <img> in a sizing span when no skeleton is shown, so className keeps resolving against the real parent", () => {
|
|
74
|
+
const { container } = render(<Image {...BASE_PROPS} alt="" className="w-full" />);
|
|
75
|
+
expect(container.firstElementChild?.tagName).toBe("IMG");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("calls the consumer's onLoad in addition to clearing the skeleton", () => {
|
|
79
|
+
const onLoad = vi.fn();
|
|
80
|
+
const { container } = render(
|
|
81
|
+
<Image {...BASE_PROPS} alt="" width={64} height={64} onLoad={onLoad} />,
|
|
82
|
+
);
|
|
83
|
+
fireEvent.load(container.querySelector("img")!);
|
|
84
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("Image onError fallback (visual regression: broken image bled through the pulsing Skeleton)", () => {
|
|
89
|
+
it("replaces the img with an ImageOff fallback box instead of leaving the Skeleton pulsing forever", () => {
|
|
90
|
+
const { container } = render(<Image {...BASE_PROPS} alt="A generated image" />);
|
|
91
|
+
fireEvent.error(container.querySelector("img")!);
|
|
92
|
+
// No native <img> (and its browser broken-image glyph) remains in the DOM.
|
|
93
|
+
expect(container.querySelector("img")).toBeNull();
|
|
94
|
+
// No skeleton keeps pulsing behind a failed image.
|
|
95
|
+
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
96
|
+
expect(container.querySelector("svg")).not.toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("calls the consumer's onError in addition to showing the fallback", () => {
|
|
100
|
+
const onError = vi.fn();
|
|
101
|
+
const { container } = render(<Image {...BASE_PROPS} alt="" onError={onError} />);
|
|
102
|
+
fireEvent.error(container.querySelector("img")!);
|
|
103
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
});
|