@linktr.ee/messaging-react 4.4.3 → 4.4.4-rc-1788190776

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 (59) hide show
  1. package/dist/AttachmentCard-BbsjzdLJ.cjs +2 -0
  2. package/dist/AttachmentCard-BbsjzdLJ.cjs.map +1 -0
  3. package/dist/{AttachmentCard-BqwWgqvi.js → AttachmentCard-DSekwIOA.js} +35 -20
  4. package/dist/AttachmentCard-DSekwIOA.js.map +1 -0
  5. package/dist/{Card-BIbqWagz.cjs → Card-BS6Fza2a.cjs} +2 -2
  6. package/dist/{Card-BIbqWagz.cjs.map → Card-BS6Fza2a.cjs.map} +1 -1
  7. package/dist/{Card-f51K6V7Y.js → Card-BhwBkgxI.js} +2 -2
  8. package/dist/{Card-f51K6V7Y.js.map → Card-BhwBkgxI.js.map} +1 -1
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.js +67 -42
  12. package/dist/index.js.map +1 -1
  13. package/package.json +3 -3
  14. package/src/components/AttachmentCard/MediaPlayer.test.tsx +62 -85
  15. package/src/components/AttachmentCard/MediaPlayer.tsx +14 -2
  16. package/src/components/AttachmentCard/Thumbnail.test.tsx +37 -46
  17. package/src/components/AttachmentCard/index.test.tsx +18 -46
  18. package/src/components/AttachmentCard/index.tsx +8 -2
  19. package/src/components/AttachmentCard/utils/icons.ts +11 -1
  20. package/src/components/Avatar/Avatar.test.tsx +37 -64
  21. package/src/components/Avatar/index.tsx +8 -1
  22. package/src/components/ChannelList/CustomChannelPreview.test.tsx +19 -19
  23. package/src/components/ChannelList/CustomChannelPreview.tsx +8 -2
  24. package/src/components/ChannelView.outbound-resolution.integration.test.tsx +25 -30
  25. package/src/components/ChannelView.test.tsx +17 -31
  26. package/src/components/ChannelView.tsx +1 -0
  27. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +8 -5
  28. package/src/components/CustomLinkPreviewList/index.tsx +4 -1
  29. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -21
  30. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +2 -0
  31. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +43 -56
  32. package/src/components/CustomMessageInput/index.tsx +25 -5
  33. package/src/components/CustomSystemMessage/CustomSystemMessage.test.tsx +17 -23
  34. package/src/components/FaqList/FaqList.test.tsx +5 -4
  35. package/src/components/FaqList/index.tsx +6 -1
  36. package/src/components/LinkAttachment/LinkAttachment.test.tsx +70 -81
  37. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
  38. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +20 -61
  39. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +10 -2
  40. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +1 -21
  41. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +11 -37
  42. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -2
  43. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +65 -72
  44. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +13 -2
  45. package/src/components/MessageAttachment/MessageAttachment.test.tsx +25 -8
  46. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +5 -19
  47. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +7 -9
  48. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +9 -2
  49. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +1 -0
  50. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +2 -2
  51. package/src/components/MessageBubble/MessageBubble.test.tsx +0 -1
  52. package/src/components/RichCard/RichCard.test.tsx +24 -54
  53. package/src/components/RichCard/RichCardBody.tsx +4 -1
  54. package/src/hooks/useChannelModerationActions.ts +15 -30
  55. package/src/logging/index.test.tsx +1 -0
  56. package/src/providers/MessagingProvider.test.tsx +37 -41
  57. package/dist/AttachmentCard-BqwWgqvi.js.map +0 -1
  58. package/dist/AttachmentCard-DUrgGRsq.cjs +0 -2
  59. package/dist/AttachmentCard-DUrgGRsq.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "4.4.3",
3
+ "version": "4.4.4-rc-1788190776",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,8 +50,8 @@
50
50
  "chromatic:local": "yarn storybook:build && chromatic"
51
51
  },
52
52
  "dependencies": {
53
- "@linktr.ee/messaging-core": "^2.6.0",
54
- "@linktr.ee/messaging-taxonomy": "^0.11.0",
53
+ "@linktr.ee/messaging-core": "2.6.0-rc-1788190776",
54
+ "@linktr.ee/messaging-taxonomy": "0.11.0-rc-1788190776",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
57
57
  "devDependencies": {
@@ -30,7 +30,7 @@ describe('MediaPlayer', () => {
30
30
  })
31
31
 
32
32
  it('selects audio or video from the mime type and wires media props', () => {
33
- const { container, rerender } = renderWithProviders(
33
+ const { rerender } = renderWithProviders(
34
34
  <MediaPlayer
35
35
  source={SOURCE}
36
36
  mimeType="audio/mpeg"
@@ -40,12 +40,11 @@ describe('MediaPlayer', () => {
40
40
  />
41
41
  )
42
42
 
43
- const audio = container.querySelector('audio') as HTMLAudioElement
44
- expect(audio).toBeInTheDocument()
43
+ const audio = screen.getByTestId('media-player-audio') as HTMLAudioElement
45
44
  expect(audio).toHaveAttribute('src', SOURCE)
46
45
  expect(audio.loop).toBe(true)
47
46
  expect(audio.muted).toBe(true)
48
- expect(container.querySelector('video')).toBeNull()
47
+ expect(screen.queryByTestId('media-player-video')).not.toBeInTheDocument()
49
48
 
50
49
  rerender(
51
50
  <MediaPlayer
@@ -57,67 +56,61 @@ describe('MediaPlayer', () => {
57
56
  />
58
57
  )
59
58
 
60
- const video = container.querySelector('video') as HTMLVideoElement
61
- expect(video).toBeInTheDocument()
59
+ const video = screen.getByTestId('media-player-video') as HTMLVideoElement
62
60
  expect(video).toHaveAttribute('src', SOURCE)
63
61
  expect(video.loop).toBe(true)
64
62
  expect(video.muted).toBe(true)
65
63
  expect(video.playsInline).toBe(true)
66
- expect(container.querySelector('audio')).toBeNull()
64
+ expect(screen.queryByTestId('media-player-audio')).not.toBeInTheDocument()
67
65
  })
68
66
 
69
67
  it('keeps an audio poster after canPlay but hides a video poster', () => {
70
- const audio = renderWithProviders(
71
- <MediaPlayer source={SOURCE} mimeType="audio/mpeg" poster={POSTER} />
72
- )
73
- const audioElement = audio.container.querySelector(
74
- 'audio'
75
- ) as HTMLAudioElement
76
- expect(audio.container.querySelectorAll('img')).toHaveLength(1)
77
- fireEvent.canPlay(audioElement)
78
- expect(audio.container.querySelectorAll('img')).toHaveLength(1)
79
- audio.unmount()
80
-
81
- const video = renderWithProviders(
82
- <MediaPlayer source={SOURCE} mimeType="video/mp4" poster={POSTER} />
83
- )
84
- const videoElement = video.container.querySelector(
85
- 'video'
86
- ) as HTMLVideoElement
87
- expect(video.container.querySelectorAll('img')).toHaveLength(1)
88
- fireEvent.canPlay(videoElement)
89
- expect(video.container.querySelectorAll('img')).toHaveLength(0)
68
+ {
69
+ const view = renderWithProviders(
70
+ <MediaPlayer source={SOURCE} mimeType="audio/mpeg" poster={POSTER} />
71
+ )
72
+ expect(screen.getByTestId('media-player-poster')).toBeInTheDocument()
73
+ fireEvent.canPlay(screen.getByTestId('media-player-audio'))
74
+ expect(screen.getByTestId('media-player-poster')).toBeInTheDocument()
75
+ view.unmount()
76
+ }
77
+
78
+ {
79
+ renderWithProviders(
80
+ <MediaPlayer source={SOURCE} mimeType="video/mp4" poster={POSTER} />
81
+ )
82
+ expect(screen.getByTestId('media-player-poster')).toBeInTheDocument()
83
+ fireEvent.canPlay(screen.getByTestId('media-player-video'))
84
+ expect(
85
+ screen.queryByTestId('media-player-poster')
86
+ ).not.toBeInTheDocument()
87
+ }
90
88
  })
91
89
 
92
90
  it('uses a type icon as the initial placeholder when no poster is provided', async () => {
93
- const { container } = renderWithProviders(
94
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
95
- )
96
- const video = container.querySelector('video') as HTMLVideoElement
97
- const placeholderIcon = () =>
98
- Array.from(container.querySelectorAll('svg')).find((svg) =>
99
- svg.classList.contains('size-12')
100
- )
91
+ renderWithProviders(<MediaPlayer source={SOURCE} mimeType="video/mp4" />)
101
92
 
102
- expect(placeholderIcon()).toBeInTheDocument()
103
- fireEvent.canPlay(video)
104
- await waitFor(() => expect(placeholderIcon()).toBeUndefined())
93
+ expect(screen.getByTestId('media-player-placeholder')).toBeInTheDocument()
94
+ fireEvent.canPlay(screen.getByTestId('media-player-video'))
95
+ await waitFor(() =>
96
+ expect(
97
+ screen.queryByTestId('media-player-placeholder')
98
+ ).not.toBeInTheDocument()
99
+ )
105
100
  })
106
101
 
107
102
  it('syncs controlled playback and calls play or pause imperatively', async () => {
108
103
  const play = vi.mocked(HTMLMediaElement.prototype.play)
109
104
  const pause = vi.mocked(HTMLMediaElement.prototype.pause)
110
- const { container, rerender } = renderWithProviders(
105
+ const { rerender } = renderWithProviders(
111
106
  <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={false} />
112
107
  )
113
- const video = container.querySelector('video') as HTMLVideoElement
114
108
  expect(pause).toHaveBeenCalled()
115
109
 
116
110
  rerender(
117
111
  <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={true} />
118
112
  )
119
113
  await waitFor(() => expect(play).toHaveBeenCalledWith())
120
- expect(video).toBeInTheDocument()
121
114
 
122
115
  rerender(
123
116
  <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={false} />
@@ -150,19 +143,15 @@ describe('MediaPlayer', () => {
150
143
 
151
144
  it('toggles from the container only with controls, while an override owns the click', async () => {
152
145
  const play = vi.mocked(HTMLMediaElement.prototype.play)
153
- const { container, rerender } = renderWithProviders(
146
+ const { rerender } = renderWithProviders(
154
147
  <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
155
148
  )
156
- const root = container.firstElementChild as HTMLElement
149
+ const root = screen.getByTestId('media-player')
157
150
 
158
151
  fireEvent.click(root)
159
- await waitFor(() =>
160
- expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
161
- )
152
+ await screen.findByRole('button', { name: 'Pause' })
162
153
  fireEvent.click(root)
163
- await waitFor(() =>
164
- expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
165
- )
154
+ await screen.findByRole('button', { name: 'Play' })
166
155
  expect(play).toHaveBeenCalledTimes(1)
167
156
 
168
157
  const onContainerClick = vi.fn()
@@ -182,10 +171,10 @@ describe('MediaPlayer', () => {
182
171
  })
183
172
 
184
173
  it('handles Enter and Space but ignores other container keys and prevents defaults', () => {
185
- const { container } = renderWithProviders(
174
+ renderWithProviders(
186
175
  <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
187
176
  )
188
- const root = container.firstElementChild as HTMLElement
177
+ const root = screen.getByTestId('media-player')
189
178
  const pause = () => screen.getByRole('button', { name: 'Pause' })
190
179
 
191
180
  const otherKey = new KeyboardEvent('keydown', {
@@ -217,22 +206,18 @@ describe('MediaPlayer', () => {
217
206
  })
218
207
 
219
208
  it('does not let the controls button bubble into the container toggle', async () => {
220
- const { container } = renderWithProviders(
209
+ renderWithProviders(
221
210
  <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
222
211
  )
223
- const button = screen.getByRole('button', { name: 'Play' })
224
212
 
225
- fireEvent.click(button)
226
- await waitFor(() =>
227
- expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
228
- )
229
- expect(container.firstElementChild).toBeInTheDocument()
213
+ fireEvent.click(screen.getByRole('button', { name: 'Play' }))
214
+ await screen.findByRole('button', { name: 'Pause' })
230
215
  })
231
216
 
232
217
  it('shows buffering while loading and suppresses it after manual play is required', async () => {
233
218
  const play = vi.mocked(HTMLMediaElement.prototype.play)
234
219
  play.mockRejectedValueOnce(new Error('autoplay blocked'))
235
- const { container } = renderWithProviders(
220
+ renderWithProviders(
236
221
  <MediaPlayer
237
222
  source={SOURCE}
238
223
  mimeType="video/mp4"
@@ -240,42 +225,39 @@ describe('MediaPlayer', () => {
240
225
  controls={false}
241
226
  />
242
227
  )
243
- const video = container.querySelector('video') as HTMLVideoElement
228
+ const video = screen.getByTestId('media-player-video')
244
229
  await screen.findByRole('button', { name: 'Play preview' })
245
230
 
246
231
  fireEvent.loadStart(video)
247
- expect(container.querySelector('.animate-spin')).toBeNull()
232
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
248
233
 
249
234
  fireEvent.canPlay(video)
250
- expect(container.querySelector('.animate-spin')).toBeNull()
235
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
251
236
  })
252
237
 
253
238
  it('shows and hides the buffering spinner around media readiness', () => {
254
- const { container } = renderWithProviders(
255
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
256
- )
257
- const video = container.querySelector('video') as HTMLVideoElement
239
+ renderWithProviders(<MediaPlayer source={SOURCE} mimeType="video/mp4" />)
240
+ const video = screen.getByTestId('media-player-video')
258
241
 
259
242
  fireEvent.loadStart(video)
260
- expect(container.querySelector('.animate-spin')).toBeInTheDocument()
243
+ expect(screen.getByRole('status')).toBeInTheDocument()
261
244
  fireEvent.waiting(video)
262
- expect(container.querySelector('.animate-spin')).toBeInTheDocument()
245
+ expect(screen.getByRole('status')).toBeInTheDocument()
263
246
  fireEvent.canPlay(video)
264
- expect(container.querySelector('.animate-spin')).toBeNull()
247
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
265
248
  })
266
249
 
267
- it('seeks with keyboard and pointer input and expands the scrubber on hover', () => {
268
- const { container } = renderWithProviders(
250
+ it('seeks with keyboard and pointer input and reveals the scrubber thumb on hover', () => {
251
+ renderWithProviders(
269
252
  <MediaPlayer source={SOURCE} mimeType="video/mp4" controls showProgress />
270
253
  )
271
- const video = container.querySelector('video') as HTMLVideoElement
254
+ const video = screen.getByTestId('media-player-video') as HTMLVideoElement
272
255
  Object.defineProperty(video, 'duration', {
273
256
  configurable: true,
274
257
  value: 100,
275
258
  })
276
259
  const slider = screen.getByRole('slider', { name: 'Playback position' })
277
- const track = slider.firstElementChild as HTMLElement
278
- const thumb = slider.lastElementChild as HTMLElement
260
+ const thumb = screen.getByTestId('media-player-scrubber-thumb')
279
261
  vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
280
262
  left: 100,
281
263
  right: 300,
@@ -288,18 +270,15 @@ describe('MediaPlayer', () => {
288
270
  toJSON: () => ({}),
289
271
  })
290
272
 
291
- expect(track).toHaveClass('h-1')
292
273
  expect(thumb).toHaveClass('scale-0', 'opacity-0')
293
274
  fireEvent.mouseEnter(slider)
294
- expect(track).toHaveClass('h-1.5')
295
275
  expect(thumb).toHaveClass('scale-100', 'opacity-100')
296
276
  fireEvent.mouseLeave(slider)
297
- expect(track).toHaveClass('h-1')
277
+ expect(thumb).toHaveClass('scale-0', 'opacity-0')
298
278
 
299
279
  fireEvent.mouseDown(slider, { clientX: 150 })
300
280
  expect(slider).toHaveAttribute('aria-valuenow', '25')
301
281
  expect(video.currentTime).toBe(25)
302
- expect(track).toHaveClass('h-1.5')
303
282
  expect(thumb).toHaveClass('scale-100', 'opacity-100')
304
283
 
305
284
  fireEvent.mouseUp(window, { clientX: 150 })
@@ -310,7 +289,7 @@ describe('MediaPlayer', () => {
310
289
  })
311
290
 
312
291
  it('renders the progress-only track without controls when requested', () => {
313
- const { container } = renderWithProviders(
292
+ renderWithProviders(
314
293
  <MediaPlayer
315
294
  source={SOURCE}
316
295
  mimeType="video/mp4"
@@ -323,15 +302,13 @@ describe('MediaPlayer', () => {
323
302
  screen.getByRole('slider', { name: 'Playback position' })
324
303
  ).toBeInTheDocument()
325
304
  expect(screen.queryByRole('button', { name: 'Play' })).toBeNull()
326
- expect(container.querySelectorAll('[role="slider"]')).toHaveLength(1)
305
+ expect(screen.getAllByRole('slider')).toHaveLength(1)
327
306
  })
328
307
 
329
308
  it('uses natural video dimensions after metadata and aspect-video before then', () => {
330
- const { container } = renderWithProviders(
331
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
332
- )
333
- const root = container.firstElementChild as HTMLElement
334
- const video = container.querySelector('video') as HTMLVideoElement
309
+ renderWithProviders(<MediaPlayer source={SOURCE} mimeType="video/mp4" />)
310
+ const root = screen.getByTestId('media-player')
311
+ const video = screen.getByTestId('media-player-video') as HTMLVideoElement
335
312
  Object.defineProperties(video, {
336
313
  videoWidth: { configurable: true, value: 1920 },
337
314
  videoHeight: { configurable: true, value: 1080 },
@@ -196,6 +196,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
196
196
  <div
197
197
  role="button"
198
198
  tabIndex={0}
199
+ data-testid="media-player"
199
200
  className={`relative cursor-pointer overflow-hidden bg-black ${aspectClass}`}
200
201
  style={aspectStyle}
201
202
  onClick={(e) => {
@@ -222,11 +223,15 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
222
223
  <img
223
224
  src={poster}
224
225
  alt=""
226
+ data-testid="media-player-poster"
225
227
  className="absolute inset-0 h-full w-full object-cover"
226
228
  />
227
229
  )}
228
230
  {!poster && (sourceType === 'audio' || initialLoad) && (
229
- <div className="absolute inset-0 flex items-center justify-center">
231
+ <div
232
+ data-testid="media-player-placeholder"
233
+ className="absolute inset-0 flex items-center justify-center"
234
+ >
230
235
  {renderTypeIcon(mimeType, {
231
236
  className: 'size-12 text-black/20',
232
237
  weight: 'regular',
@@ -238,6 +243,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
238
243
  <audio
239
244
  {...nativePlayerProps}
240
245
  ref={playerRef as React.RefObject<HTMLAudioElement>}
246
+ data-testid="media-player-audio"
241
247
  src={source}
242
248
  loop={loop}
243
249
  muted={muted}
@@ -261,6 +267,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
261
267
  ) : (
262
268
  <video
263
269
  ref={playerRef as React.RefObject<HTMLVideoElement>}
270
+ data-testid="media-player-video"
264
271
  src={source}
265
272
  loop={loop}
266
273
  muted={muted}
@@ -296,7 +303,11 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
296
303
  </div>
297
304
 
298
305
  {buffering && !manualPlayRequired && (
299
- <div className="absolute inset-0 z-10 flex items-center justify-center">
306
+ <div
307
+ role="status"
308
+ aria-label="Loading media"
309
+ className="absolute inset-0 z-10 flex items-center justify-center"
310
+ >
300
311
  <CircleNotchIcon
301
312
  className="size-8 animate-spin text-white/80"
302
313
  weight="bold"
@@ -396,6 +407,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
396
407
  />
397
408
  </div>
398
409
  <div
410
+ data-testid="media-player-scrubber-thumb"
399
411
  className={`absolute size-3 -translate-x-1/2 rounded-full bg-white shadow transition-[opacity,transform] duration-200 ${scrubberHovered || seeking ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
400
412
  style={{ left: `${scrubberPercent}%` }}
401
413
  />
@@ -147,69 +147,60 @@ describe('AttachmentThumbnail', () => {
147
147
  })
148
148
 
149
149
  it('renders a document icon placeholder without a thumbnail', () => {
150
- const dark = renderWithProviders(
150
+ renderWithProviders(
151
151
  <AttachmentThumbnail
152
152
  mimeType="application/pdf"
153
153
  variant="dark"
154
154
  title="PDF"
155
155
  />
156
156
  )
157
- const darkIcon = dark.container.querySelector('svg') as SVGElement
158
- expect(darkIcon).toHaveClass('size-12', 'text-white/20')
159
- expect(dark.container.firstElementChild).toHaveClass('bg-white/10')
160
- dark.unmount()
161
-
162
- const light = renderWithProviders(
163
- <AttachmentThumbnail mimeType="application/pdf" variant="light" />
164
- )
165
- const lightIcon = light.container.querySelector('svg') as SVGElement
166
- expect(lightIcon).toHaveClass('size-12', 'text-black/20')
167
- expect(light.container.firstElementChild).toHaveClass('bg-black/5')
157
+ expect(screen.queryByRole('img')).toBeNull()
158
+ expect(screen.getByTestId('type-icon-pdf')).toBeInTheDocument()
168
159
  })
169
160
 
170
161
  it('renders the poster-only branch with variant-specific shell and alt fallback', () => {
171
- const dark = renderWithProviders(
172
- <AttachmentThumbnail
173
- mimeType="image/jpeg"
174
- thumbnailUrl={LINKTREE_THUMBNAIL}
175
- title="Poster"
176
- variant="dark"
177
- />
178
- )
179
- const darkImage = screen.getByRole('img', { name: 'Poster' })
180
- expect(darkImage).toHaveAttribute('src', OPTIMIZED_THUMBNAIL)
181
- expect(dark.container.firstElementChild).toHaveClass(
182
- 'aspect-video',
183
- 'bg-white/10'
184
- )
185
- dark.unmount()
162
+ {
163
+ const view = renderWithProviders(
164
+ <AttachmentThumbnail
165
+ mimeType="image/jpeg"
166
+ thumbnailUrl={LINKTREE_THUMBNAIL}
167
+ title="Poster"
168
+ variant="dark"
169
+ />
170
+ )
171
+ const darkImage = screen.getByRole('img', { name: 'Poster' })
172
+ expect(darkImage).toHaveAttribute('src', OPTIMIZED_THUMBNAIL)
173
+ expect(view.container.firstElementChild).toHaveClass(
174
+ 'aspect-video',
175
+ 'bg-white/10'
176
+ )
177
+ view.unmount()
178
+ }
186
179
 
187
- const light = renderWithProviders(
188
- <AttachmentThumbnail
189
- mimeType="image/jpeg"
190
- thumbnailUrl="https://external.example/poster.jpg"
191
- variant="light"
192
- />
193
- )
194
- expect(screen.getByRole('img')).toHaveAttribute('alt', '')
195
- expect(light.container.firstElementChild).toHaveClass(
196
- 'aspect-video',
197
- 'bg-black/5'
198
- )
180
+ {
181
+ const view = renderWithProviders(
182
+ <AttachmentThumbnail
183
+ mimeType="image/jpeg"
184
+ thumbnailUrl="https://external.example/poster.jpg"
185
+ variant="light"
186
+ />
187
+ )
188
+ expect(screen.getByRole('img')).toHaveAttribute('alt', '')
189
+ expect(view.container.firstElementChild).toHaveClass(
190
+ 'aspect-video',
191
+ 'bg-black/5'
192
+ )
193
+ }
199
194
  })
200
195
 
201
196
  it('renders the empty branch with a light placeholder', () => {
202
- const { container } = renderWithProviders(
197
+ renderWithProviders(
203
198
  <AttachmentThumbnail
204
199
  mimeType="application/octet-stream"
205
200
  variant="light"
206
201
  />
207
202
  )
208
- expect(container.querySelector('img')).toBeNull()
209
- expect(container.querySelector('svg')).toHaveClass(
210
- 'size-12',
211
- 'text-black/20'
212
- )
213
- expect(container.firstElementChild).toHaveClass('bg-black/5')
203
+ expect(screen.queryByRole('img')).toBeNull()
204
+ expect(screen.getByTestId('type-icon-generic')).toBeInTheDocument()
214
205
  })
215
206
  })
@@ -8,33 +8,7 @@ import AttachmentCard from './index'
8
8
  const thumbnail = <div data-testid="thumbnail">thumbnail</div>
9
9
 
10
10
  describe('AttachmentCard', () => {
11
- it('applies the dark and light root surfaces and type icon colors', () => {
12
- const dark = renderWithProviders(
13
- <AttachmentCard
14
- variant="dark"
15
- thumbnail={thumbnail}
16
- mimeType="application/pdf"
17
- title="Dark file"
18
- />
19
- )
20
- expect(screen.getByText('Dark file')).toBeInTheDocument()
21
- expect(dark.container.firstElementChild).toHaveClass('bg-[#1e2330]')
22
- expect(dark.container.querySelector('svg')).toHaveClass('text-white/55')
23
- dark.unmount()
24
-
25
- const light = renderWithProviders(
26
- <AttachmentCard
27
- variant="light"
28
- thumbnail={thumbnail}
29
- mimeType="application/pdf"
30
- title="Light file"
31
- />
32
- )
33
- expect(light.container.firstElementChild).toHaveClass('bg-white')
34
- expect(light.container.querySelector('svg')).toHaveClass('text-black/55')
35
- })
36
-
37
- it('uses the dark placeholder title and dims it when title is absent', () => {
11
+ it('renders the dark placeholder title when no title is supplied', () => {
38
12
  renderWithProviders(
39
13
  <AttachmentCard
40
14
  variant="dark"
@@ -43,7 +17,7 @@ describe('AttachmentCard', () => {
43
17
  placeholderTitle="Waiting for title"
44
18
  />
45
19
  )
46
- expect(screen.getByText('Waiting for title')).toHaveClass('text-white/30')
20
+ expect(screen.getByText('Waiting for title')).toBeInTheDocument()
47
21
  })
48
22
 
49
23
  it('does not render a light title for missing or whitespace-only values', () => {
@@ -67,7 +41,7 @@ describe('AttachmentCard', () => {
67
41
  expect(screen.queryByText(' ')).toBeNull()
68
42
  })
69
43
 
70
- it('uses an undimmed title class when a dark title is provided', () => {
44
+ it('renders the supplied dark title instead of the placeholder', () => {
71
45
  renderWithProviders(
72
46
  <AttachmentCard
73
47
  variant="dark"
@@ -76,8 +50,8 @@ describe('AttachmentCard', () => {
76
50
  title="Named file"
77
51
  />
78
52
  )
79
- expect(screen.getByText('Named file')).toHaveClass('text-white')
80
- expect(screen.getByText('Named file')).not.toHaveClass('text-white/30')
53
+ expect(screen.getByText('Named file')).toBeInTheDocument()
54
+ expect(screen.queryByText('Attachment title')).toBeNull()
81
55
  })
82
56
 
83
57
  it('renders detail only for non-empty values', () => {
@@ -105,7 +79,7 @@ describe('AttachmentCard', () => {
105
79
  })
106
80
 
107
81
  it('renders optional slots only when supplied', () => {
108
- const { container } = renderWithProviders(
82
+ const view = renderWithProviders(
109
83
  <AttachmentCard
110
84
  variant="light"
111
85
  thumbnail={thumbnail}
@@ -118,21 +92,16 @@ describe('AttachmentCard', () => {
118
92
  )
119
93
  expect(screen.getByText('Ready')).toBeInTheDocument()
120
94
  expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument()
121
- expect(screen.getByText('Left').parentElement).toHaveClass(
122
- 'pointer-events-auto',
123
- 'absolute',
124
- 'left-3',
125
- 'top-3'
95
+ expect(screen.getByTestId('attachment-card-top-left')).toContainElement(
96
+ screen.getByText('Left')
126
97
  )
127
- expect(screen.getByText('Right').parentElement).toHaveClass(
128
- 'pointer-events-auto',
129
- 'absolute',
130
- 'right-3',
131
- 'top-3'
98
+ expect(screen.getByTestId('attachment-card-top-right')).toContainElement(
99
+ screen.getByText('Right')
132
100
  )
133
- expect(container.querySelectorAll('.pointer-events-auto')).toHaveLength(2)
134
101
 
135
- const withoutSlots = renderWithProviders(
102
+ view.unmount()
103
+
104
+ renderWithProviders(
136
105
  <AttachmentCard
137
106
  variant="light"
138
107
  thumbnail={thumbnail}
@@ -140,8 +109,11 @@ describe('AttachmentCard', () => {
140
109
  />
141
110
  )
142
111
  expect(
143
- withoutSlots.container.querySelectorAll('.pointer-events-auto')
144
- ).toHaveLength(0)
112
+ screen.queryByTestId('attachment-card-top-left')
113
+ ).not.toBeInTheDocument()
114
+ expect(
115
+ screen.queryByTestId('attachment-card-top-right')
116
+ ).not.toBeInTheDocument()
145
117
  })
146
118
 
147
119
  it('forwards rootRef and data-testid to the root', () => {
@@ -59,12 +59,18 @@ const AttachmentCard: React.FC<AttachmentCardProps> = ({
59
59
  )}
60
60
  >
61
61
  {topLeft ? (
62
- <div className="pointer-events-auto absolute left-3 top-3 z-50">
62
+ <div
63
+ data-testid="attachment-card-top-left"
64
+ className="pointer-events-auto absolute left-3 top-3 z-50"
65
+ >
63
66
  {topLeft}
64
67
  </div>
65
68
  ) : null}
66
69
  {topRight ? (
67
- <div className="pointer-events-auto absolute right-3 top-3 z-50">
70
+ <div
71
+ data-testid="attachment-card-top-right"
72
+ className="pointer-events-auto absolute right-3 top-3 z-50"
73
+ >
68
74
  {topRight}
69
75
  </div>
70
76
  ) : null}
@@ -43,10 +43,20 @@ function getTypeIcon(mimeType: string): React.ElementType {
43
43
  return DOCUMENT_ICON_COMPONENT[getDocumentIconType(mimeType)]
44
44
  }
45
45
 
46
+ function getTypeIconName(mimeType: string): string {
47
+ const sourceType = getSourceType(mimeType)
48
+ if (sourceType !== 'document') return sourceType
49
+ return getDocumentIconType(mimeType)
50
+ }
51
+
46
52
  /** Use instead of `<TypeIcon />` where TypeIcon = getTypeIcon(mime) to satisfy react-hooks/static-components. */
47
53
  export function renderTypeIcon(
48
54
  mimeType: string,
49
55
  props: IconProps
50
56
  ): React.ReactElement {
51
- return React.createElement(getTypeIcon(mimeType), props)
57
+ // Decorative icons: which one was picked is otherwise only visible in a pixel diff.
58
+ return React.createElement(getTypeIcon(mimeType), {
59
+ 'data-testid': `type-icon-${getTypeIconName(mimeType)}`,
60
+ ...props,
61
+ })
52
62
  }