@elabs-ai/components-ai 5.3.1 → 5.5.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 (115) hide show
  1. package/README.md +4 -6
  2. package/dist/_lazy-engine-boundary.js +1 -1
  3. package/dist/_persona-rive.js.map +1 -1
  4. package/dist/_streamdown-i18n.js +2 -2
  5. package/dist/agent.js +2 -2
  6. package/dist/asset-preview.js +5 -5
  7. package/dist/attachments.js +1 -1
  8. package/dist/audio-player.js +1 -2
  9. package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
  10. package/dist/chunk-24M6XWVJ.js.map +1 -0
  11. package/dist/{chunk-WU5BLGTF.js → chunk-3KXIWGHW.js} +3 -3
  12. package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
  13. package/dist/chunk-3UHR3RLZ.js.map +1 -0
  14. package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
  15. package/dist/chunk-D7BZMRI3.js +2 -0
  16. package/dist/chunk-D7BZMRI3.js.map +1 -0
  17. package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
  18. package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
  19. package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
  20. package/dist/chunk-FO44KY7Y.js.map +1 -0
  21. package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
  22. package/dist/chunk-GP7ZZCID.js.map +1 -0
  23. package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
  24. package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
  25. package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
  26. package/dist/chunk-JSEV46SH.js.map +1 -0
  27. package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
  28. package/dist/{chunk-PTWCTSZL.js → chunk-OCZNA5SO.js} +4 -4
  29. package/dist/chunk-OCZNA5SO.js.map +1 -0
  30. package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
  31. package/dist/chunk-PWQPVOZJ.js.map +1 -0
  32. package/dist/chunk-RLV64EWK.js +41 -0
  33. package/dist/chunk-RLV64EWK.js.map +1 -0
  34. package/dist/chunk-SSES3F4Q.js +126 -0
  35. package/dist/chunk-SSES3F4Q.js.map +1 -0
  36. package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
  37. package/dist/chunk-TBCMCF5O.js.map +1 -0
  38. package/dist/{chunk-FERYDFZA.js → chunk-WXUXFDJQ.js} +2 -2
  39. package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
  40. package/dist/code-block.js +1 -1
  41. package/dist/diff-view.js +2 -2
  42. package/dist/gallery.js +1 -1
  43. package/dist/generated-image.js +9 -0
  44. package/dist/generated-image.js.map +1 -0
  45. package/dist/grouped-parts.js +6 -6
  46. package/dist/image.js +4 -4
  47. package/dist/index.d.ts +92 -150
  48. package/dist/index.js +67 -65
  49. package/dist/markdown-view.js +3 -3
  50. package/dist/message.js +3 -3
  51. package/dist/model-provider-logo.js +1 -1
  52. package/dist/persona.js +2 -2
  53. package/dist/queue.js +1 -1
  54. package/dist/reasoning.js +3 -3
  55. package/dist/sandbox.js +3 -3
  56. package/dist/tool.js +2 -2
  57. package/package.json +8 -13
  58. package/schemas/a2ui-surface.v1.schema.json +5 -2
  59. package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
  60. package/src/__contract__/generated-image.contract.test.tsx +49 -0
  61. package/src/_lazy-boundary-conformance.ts +12 -18
  62. package/src/_lazy-engine-boundary.tsx +1 -2
  63. package/src/_lazy-mermaid-absent.test.ts +2 -2
  64. package/src/_media-fixtures.ts +47 -0
  65. package/src/_persona-rive.tsx +1 -2
  66. package/src/a2ui/catalog.source.json +3 -2
  67. package/src/asset-preview.stories.tsx +12 -0
  68. package/src/asset-preview.test.tsx +14 -0
  69. package/src/asset-preview.tsx +4 -2
  70. package/src/attachments.stories.tsx +147 -0
  71. package/src/attachments.test.tsx +104 -0
  72. package/src/attachments.tsx +28 -20
  73. package/src/audio-player.stories.tsx +10 -45
  74. package/src/audio-player.test.tsx +166 -61
  75. package/src/audio-player.tsx +175 -266
  76. package/src/chat.stories.tsx +75 -3
  77. package/src/code-block.test.tsx +32 -0
  78. package/src/code-block.tsx +22 -5
  79. package/src/gallery.tsx +13 -77
  80. package/src/generated-image.stories.tsx +67 -0
  81. package/src/generated-image.test.tsx +107 -0
  82. package/src/generated-image.tsx +57 -0
  83. package/src/image.tsx +2 -115
  84. package/src/index.ts +1 -0
  85. package/src/model-provider-logo.tsx +20 -18
  86. package/src/queue.test.tsx +24 -0
  87. package/src/queue.tsx +8 -5
  88. package/src/tool.test.tsx +26 -0
  89. package/src/tool.tsx +2 -2
  90. package/dist/_audio-player-media-chrome.js +0 -85
  91. package/dist/_audio-player-media-chrome.js.map +0 -1
  92. package/dist/chunk-2XOIG5TG.js.map +0 -1
  93. package/dist/chunk-34CQ2VHM.js.map +0 -1
  94. package/dist/chunk-3XNPMDZY.js.map +0 -1
  95. package/dist/chunk-7RL6KZDF.js.map +0 -1
  96. package/dist/chunk-NSCLRHES.js +0 -102
  97. package/dist/chunk-NSCLRHES.js.map +0 -1
  98. package/dist/chunk-PTWCTSZL.js.map +0 -1
  99. package/dist/chunk-RB7G65JC.js.map +0 -1
  100. package/dist/chunk-S3ISPW3A.js.map +0 -1
  101. package/dist/chunk-TWPHAJNM.js.map +0 -1
  102. package/dist/chunk-V6E5DYZD.js +0 -80
  103. package/dist/chunk-V6E5DYZD.js.map +0 -1
  104. package/src/_audio-player-media-chrome.tsx +0 -233
  105. package/src/image.stories.tsx +0 -67
  106. package/src/image.test.tsx +0 -105
  107. /package/dist/{chunk-WU5BLGTF.js.map → chunk-3KXIWGHW.js.map} +0 -0
  108. /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
  109. /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
  110. /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
  111. /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
  112. /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
  113. /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
  114. /package/dist/{chunk-FERYDFZA.js.map → chunk-WXUXFDJQ.js.map} +0 -0
  115. /package/dist/{chunk-X47QMM6K.js.map → chunk-YLN7E4OV.js.map} +0 -0
@@ -1,233 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * The media-chrome half of `AudioPlayer`, split out so it can be `lazy()`-loaded.
5
- *
6
- * `media-chrome` declares no `sideEffects`, so a static import from
7
- * `audio-player.tsx` put the whole custom-element library (and the
8
- * `media-chrome/react` wrappers that register it) into the entry chunk of every
9
- * `@elabs-ai/components-ai` consumer — including the vast majority that
10
- * never render an `AudioPlayer`. Keeping every media-chrome *value* import in
11
- * this module, reached only through
12
- * `lazy(() => import("./_audio-player-media-chrome"))`, confines it to its own
13
- * chunk. `audio-player.tsx` owns the public prop types, and — since issue #101
14
- * — owns them as OWNED types (`AudioPlayerPartProps` and its ten aliases) that
15
- * never reference `media-chrome/react`'s own types; this module still imports
16
- * the REAL media-chrome values (below) and, at the bottom of the file, proves
17
- * every owned type stays assignable to its real counterpart.
18
- *
19
- * See ADR 0019 and `pnpm heavy-deps:check`.
20
- *
21
- * @lazy-boundary This module must only ever be reached via `import()`. The gate
22
- * fails if anything imports it statically, which would put media-chrome back in
23
- * the entry chunk and make the `lazy()` pointless.
24
- */
25
- import { Button, ButtonGroup, ButtonGroupText } from "@elabs-ai/components-ui";
26
- import { cn } from "@elabs-ai/components-ui/lib/cn";
27
- // A NAMED import (`import { MediaController } from "media-chrome/react"`) is a
28
- // static ESM binding a bundler must resolve at build time. Now that
29
- // `media-chrome` is a genuinely optional peer (issue #33), a consumer who has
30
- // not installed it hits that resolution at the worst possible time: Vite's own
31
- // optional-peer-dependency handling swaps in a build-time stub with no
32
- // exports, and Rollup's static named-export check then fails the WHOLE APP
33
- // BUILD — not a runtime error any `.catch()`/error boundary could ever see
34
- // (confirmed against `fixtures/consumer-smoke`'s real Vite build). A namespace
35
- // import defers every one of these to a plain property lookup, which Rollup
36
- // does not statically validate, so the build always succeeds; a genuinely
37
- // missing peer instead resolves the destructured names below to `undefined`,
38
- // and the guard turns that into a render-phase throw `LazyEngineBoundary`
39
- // already catches (see `persona.tsx`'s identical pattern).
40
- import * as MediaChromeReactModule from "media-chrome/react";
41
- import type { ComponentProps, CSSProperties } from "react";
42
-
43
- import type { AssertAssignable } from "./_lazy-boundary-conformance";
44
- import type {
45
- AudioPlayerControlBarProps,
46
- AudioPlayerDurationDisplayProps,
47
- AudioPlayerMuteButtonProps,
48
- AudioPlayerPlayButtonProps,
49
- AudioPlayerProps,
50
- AudioPlayerSeekBackwardButtonProps,
51
- AudioPlayerSeekForwardButtonProps,
52
- AudioPlayerTimeDisplayProps,
53
- AudioPlayerTimeRangeProps,
54
- AudioPlayerVolumeRangeProps,
55
- } from "./audio-player";
56
-
57
- const {
58
- MediaControlBar,
59
- MediaController,
60
- MediaDurationDisplay,
61
- MediaMuteButton,
62
- MediaPlayButton,
63
- MediaSeekBackwardButton,
64
- MediaSeekForwardButton,
65
- MediaTimeDisplay,
66
- MediaTimeRange,
67
- MediaVolumeRange,
68
- } = MediaChromeReactModule;
69
-
70
- // The peer is genuinely absent (Vite's build-time stub, or any other bundler
71
- // that resolves an optional peer to an empty module) — surface a message
72
- // `isModuleNotFoundMessage` recognizes, so the caller's `renderMissing` still
73
- // gets the actionable "install media-chrome" copy rather than a raw
74
- // "MediaController is not a valid JSX element" crash.
75
- if (!MediaController) {
76
- throw new Error("Cannot find module 'media-chrome'");
77
- }
78
-
79
- export const AudioPlayer = ({ children, style, ...props }: AudioPlayerProps) => (
80
- <MediaController
81
- audio
82
- style={
83
- {
84
- "--media-background-color": "transparent",
85
- "--media-button-icon-height": "1rem",
86
- "--media-button-icon-width": "1rem",
87
- "--media-control-background": "transparent",
88
- "--media-control-hover-background": "var(--color-accent)",
89
- "--media-control-padding": "0",
90
- "--media-font": "var(--font-sans)",
91
- "--media-font-size": "10px",
92
- "--media-icon-color": "currentColor",
93
- "--media-preview-time-background": "var(--color-background)",
94
- "--media-preview-time-border-radius": "var(--radius-md)",
95
- "--media-preview-time-text-shadow": "none",
96
- "--media-primary-color": "var(--color-primary)",
97
- "--media-range-bar-color": "var(--color-primary)",
98
- "--media-range-track-background": "var(--color-secondary)",
99
- "--media-secondary-color": "var(--color-secondary)",
100
- "--media-text-color": "var(--color-foreground)",
101
- "--media-tooltip-arrow-display": "none",
102
- "--media-tooltip-background": "var(--color-background)",
103
- "--media-tooltip-border-radius": "var(--radius-md)",
104
- ...style,
105
- } as CSSProperties
106
- }
107
- {...props}
108
- >
109
- {children}
110
- </MediaController>
111
- );
112
-
113
- export const AudioPlayerControlBar = ({ children, ...props }: AudioPlayerControlBarProps) => (
114
- <MediaControlBar {...props}>
115
- <ButtonGroup orientation="horizontal">{children}</ButtonGroup>
116
- </MediaControlBar>
117
- );
118
-
119
- export const AudioPlayerPlayButton = ({ className, ...props }: AudioPlayerPlayButtonProps) => (
120
- <Button asChild size="icon-sm" variant="outline">
121
- <MediaPlayButton className={cn("bg-transparent", className)} {...props} />
122
- </Button>
123
- );
124
-
125
- export const AudioPlayerSeekBackwardButton = ({
126
- seekOffset = 10,
127
- ...props
128
- }: AudioPlayerSeekBackwardButtonProps) => (
129
- <Button asChild size="icon-sm" variant="outline">
130
- <MediaSeekBackwardButton seekOffset={seekOffset} {...props} />
131
- </Button>
132
- );
133
-
134
- export const AudioPlayerSeekForwardButton = ({
135
- seekOffset = 10,
136
- ...props
137
- }: AudioPlayerSeekForwardButtonProps) => (
138
- <Button asChild size="icon-sm" variant="outline">
139
- <MediaSeekForwardButton seekOffset={seekOffset} {...props} />
140
- </Button>
141
- );
142
-
143
- export const AudioPlayerTimeDisplay = ({ className, ...props }: AudioPlayerTimeDisplayProps) => (
144
- <ButtonGroupText asChild className="bg-transparent">
145
- <MediaTimeDisplay className={cn("tabular-nums", className)} {...props} />
146
- </ButtonGroupText>
147
- );
148
-
149
- export const AudioPlayerTimeRange = ({ className, ...props }: AudioPlayerTimeRangeProps) => (
150
- <ButtonGroupText asChild className="bg-transparent">
151
- <MediaTimeRange className={cn("", className)} {...props} />
152
- </ButtonGroupText>
153
- );
154
-
155
- export const AudioPlayerDurationDisplay = ({
156
- className,
157
- ...props
158
- }: AudioPlayerDurationDisplayProps) => (
159
- <ButtonGroupText asChild className="bg-transparent">
160
- <MediaDurationDisplay className={cn("tabular-nums", className)} {...props} />
161
- </ButtonGroupText>
162
- );
163
-
164
- export const AudioPlayerMuteButton = ({ className, ...props }: AudioPlayerMuteButtonProps) => (
165
- <ButtonGroupText asChild className="bg-transparent">
166
- <MediaMuteButton className={cn("", className)} {...props} />
167
- </ButtonGroupText>
168
- );
169
-
170
- export const AudioPlayerVolumeRange = ({ className, ...props }: AudioPlayerVolumeRangeProps) => (
171
- <ButtonGroupText asChild className="bg-transparent">
172
- <MediaVolumeRange className={cn("", className)} {...props} />
173
- </ButtonGroupText>
174
- );
175
-
176
- /**
177
- * Conformance assertions (issue #101): compile-time proof that every OWNED
178
- * type declared in `audio-player.tsx` stays assignable to its REAL
179
- * `media-chrome/react` counterpart — this module still has the real values
180
- * (and therefore the real types) in scope, since it is the one place they are
181
- * allowed to be (see the module doc comment above). `AssertAssignable`'s type
182
- * parameter is constrained (`TOwned extends TReal`), so if a future
183
- * media-chrome release narrows an element's props in a way the owned type no
184
- * longer satisfies, ONE of these ten lines fails to typecheck — caught by
185
- * `pnpm --filter @elabs-ai/components-ai typecheck` locally, never shipped as
186
- * a silent mismatch to a consumer using the real component underneath.
187
- *
188
- * `AssertAssignable` itself is shared with `_persona-rive.tsx` via
189
- * `_lazy-boundary-conformance.ts` — see that module's doc comment for what
190
- * this check can and cannot prove (it is one-directional: it cannot catch the
191
- * owned type being NARROWER than the real one, which is exactly what these
192
- * owned types are — see the CHANGELOG's "Breaking (types)" entry).
193
- */
194
- export type _AudioPlayerPropsConformance = AssertAssignable<
195
- AudioPlayerProps,
196
- Omit<ComponentProps<typeof MediaController>, "audio">
197
- >;
198
- export type _AudioPlayerControlBarPropsConformance = AssertAssignable<
199
- AudioPlayerControlBarProps,
200
- ComponentProps<typeof MediaControlBar>
201
- >;
202
- export type _AudioPlayerPlayButtonPropsConformance = AssertAssignable<
203
- AudioPlayerPlayButtonProps,
204
- ComponentProps<typeof MediaPlayButton>
205
- >;
206
- export type _AudioPlayerSeekBackwardButtonPropsConformance = AssertAssignable<
207
- AudioPlayerSeekBackwardButtonProps,
208
- ComponentProps<typeof MediaSeekBackwardButton>
209
- >;
210
- export type _AudioPlayerSeekForwardButtonPropsConformance = AssertAssignable<
211
- AudioPlayerSeekForwardButtonProps,
212
- ComponentProps<typeof MediaSeekForwardButton>
213
- >;
214
- export type _AudioPlayerTimeDisplayPropsConformance = AssertAssignable<
215
- AudioPlayerTimeDisplayProps,
216
- ComponentProps<typeof MediaTimeDisplay>
217
- >;
218
- export type _AudioPlayerTimeRangePropsConformance = AssertAssignable<
219
- AudioPlayerTimeRangeProps,
220
- ComponentProps<typeof MediaTimeRange>
221
- >;
222
- export type _AudioPlayerDurationDisplayPropsConformance = AssertAssignable<
223
- AudioPlayerDurationDisplayProps,
224
- ComponentProps<typeof MediaDurationDisplay>
225
- >;
226
- export type _AudioPlayerMuteButtonPropsConformance = AssertAssignable<
227
- AudioPlayerMuteButtonProps,
228
- ComponentProps<typeof MediaMuteButton>
229
- >;
230
- export type _AudioPlayerVolumeRangePropsConformance = AssertAssignable<
231
- AudioPlayerVolumeRangeProps,
232
- ComponentProps<typeof MediaVolumeRange>
233
- >;
@@ -1,67 +0,0 @@
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
- };
@@ -1,105 +0,0 @@
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
- });