@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.
- package/README.md +4 -6
- package/dist/_lazy-engine-boundary.js +1 -1
- package/dist/_persona-rive.js.map +1 -1
- package/dist/_streamdown-i18n.js +2 -2
- package/dist/agent.js +2 -2
- package/dist/asset-preview.js +5 -5
- package/dist/attachments.js +1 -1
- package/dist/audio-player.js +1 -2
- package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
- package/dist/chunk-24M6XWVJ.js.map +1 -0
- package/dist/{chunk-WU5BLGTF.js → chunk-3KXIWGHW.js} +3 -3
- package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
- package/dist/chunk-3UHR3RLZ.js.map +1 -0
- package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
- package/dist/chunk-D7BZMRI3.js +2 -0
- package/dist/chunk-D7BZMRI3.js.map +1 -0
- package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
- package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
- package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
- package/dist/chunk-FO44KY7Y.js.map +1 -0
- package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
- package/dist/chunk-GP7ZZCID.js.map +1 -0
- package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
- package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
- package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
- package/dist/chunk-JSEV46SH.js.map +1 -0
- package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
- package/dist/{chunk-PTWCTSZL.js → chunk-OCZNA5SO.js} +4 -4
- package/dist/chunk-OCZNA5SO.js.map +1 -0
- package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
- package/dist/chunk-PWQPVOZJ.js.map +1 -0
- package/dist/chunk-RLV64EWK.js +41 -0
- package/dist/chunk-RLV64EWK.js.map +1 -0
- package/dist/chunk-SSES3F4Q.js +126 -0
- package/dist/chunk-SSES3F4Q.js.map +1 -0
- package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
- package/dist/chunk-TBCMCF5O.js.map +1 -0
- package/dist/{chunk-FERYDFZA.js → chunk-WXUXFDJQ.js} +2 -2
- package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
- package/dist/code-block.js +1 -1
- package/dist/diff-view.js +2 -2
- package/dist/gallery.js +1 -1
- package/dist/generated-image.js +9 -0
- package/dist/generated-image.js.map +1 -0
- package/dist/grouped-parts.js +6 -6
- package/dist/image.js +4 -4
- package/dist/index.d.ts +92 -150
- package/dist/index.js +67 -65
- package/dist/markdown-view.js +3 -3
- package/dist/message.js +3 -3
- package/dist/model-provider-logo.js +1 -1
- package/dist/persona.js +2 -2
- package/dist/queue.js +1 -1
- package/dist/reasoning.js +3 -3
- package/dist/sandbox.js +3 -3
- package/dist/tool.js +2 -2
- package/package.json +8 -13
- package/schemas/a2ui-surface.v1.schema.json +5 -2
- package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
- package/src/__contract__/generated-image.contract.test.tsx +49 -0
- package/src/_lazy-boundary-conformance.ts +12 -18
- package/src/_lazy-engine-boundary.tsx +1 -2
- package/src/_lazy-mermaid-absent.test.ts +2 -2
- package/src/_media-fixtures.ts +47 -0
- package/src/_persona-rive.tsx +1 -2
- package/src/a2ui/catalog.source.json +3 -2
- package/src/asset-preview.stories.tsx +12 -0
- package/src/asset-preview.test.tsx +14 -0
- package/src/asset-preview.tsx +4 -2
- package/src/attachments.stories.tsx +147 -0
- package/src/attachments.test.tsx +104 -0
- package/src/attachments.tsx +28 -20
- package/src/audio-player.stories.tsx +10 -45
- package/src/audio-player.test.tsx +166 -61
- package/src/audio-player.tsx +175 -266
- package/src/chat.stories.tsx +75 -3
- package/src/code-block.test.tsx +32 -0
- package/src/code-block.tsx +22 -5
- package/src/gallery.tsx +13 -77
- package/src/generated-image.stories.tsx +67 -0
- package/src/generated-image.test.tsx +107 -0
- package/src/generated-image.tsx +57 -0
- package/src/image.tsx +2 -115
- package/src/index.ts +1 -0
- package/src/model-provider-logo.tsx +20 -18
- package/src/queue.test.tsx +24 -0
- package/src/queue.tsx +8 -5
- package/src/tool.test.tsx +26 -0
- package/src/tool.tsx +2 -2
- package/dist/_audio-player-media-chrome.js +0 -85
- package/dist/_audio-player-media-chrome.js.map +0 -1
- package/dist/chunk-2XOIG5TG.js.map +0 -1
- package/dist/chunk-34CQ2VHM.js.map +0 -1
- package/dist/chunk-3XNPMDZY.js.map +0 -1
- package/dist/chunk-7RL6KZDF.js.map +0 -1
- package/dist/chunk-NSCLRHES.js +0 -102
- package/dist/chunk-NSCLRHES.js.map +0 -1
- package/dist/chunk-PTWCTSZL.js.map +0 -1
- package/dist/chunk-RB7G65JC.js.map +0 -1
- package/dist/chunk-S3ISPW3A.js.map +0 -1
- package/dist/chunk-TWPHAJNM.js.map +0 -1
- package/dist/chunk-V6E5DYZD.js +0 -80
- package/dist/chunk-V6E5DYZD.js.map +0 -1
- package/src/_audio-player-media-chrome.tsx +0 -233
- package/src/image.stories.tsx +0 -67
- package/src/image.test.tsx +0 -105
- /package/dist/{chunk-WU5BLGTF.js.map → chunk-3KXIWGHW.js.map} +0 -0
- /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
- /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
- /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
- /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
- /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
- /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
- /package/dist/{chunk-FERYDFZA.js.map → chunk-WXUXFDJQ.js.map} +0 -0
- /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
|
-
>;
|
package/src/image.stories.tsx
DELETED
|
@@ -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
|
-
};
|
package/src/image.test.tsx
DELETED
|
@@ -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
|
-
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|