@linktr.ee/messaging-react 2.3.0-rc-1779427772 → 2.3.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 (49) hide show
  1. package/dist/{Card-DKp7ijLV.js → Card-4takoN_-.js} +6 -6
  2. package/dist/{Card-DKp7ijLV.js.map → Card-4takoN_-.js.map} +1 -1
  3. package/dist/{Card-Djm6JjNo.js → Card-BuROm0u7.js} +19 -19
  4. package/dist/{Card-Djm6JjNo.js.map → Card-BuROm0u7.js.map} +1 -1
  5. package/dist/{Card-BlzGsGam.cjs → Card-CexShqpK.cjs} +2 -2
  6. package/dist/{Card-BlzGsGam.cjs.map → Card-CexShqpK.cjs.map} +1 -1
  7. package/dist/{Card-BkWwtS0b.cjs → Card-CgpHBx-W.cjs} +2 -2
  8. package/dist/{Card-BkWwtS0b.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
  9. package/dist/{Card-B7yHs01-.js → Card-DdpdnSh_.js} +16 -16
  10. package/dist/{Card-B7yHs01-.js.map → Card-DdpdnSh_.js.map} +1 -1
  11. package/dist/{Card-DApWNv5V.cjs → Card-ot16XqS2.cjs} +2 -2
  12. package/dist/{Card-DApWNv5V.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-BjF6khtg.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
  14. package/dist/{LockedThumbnail-BjF6khtg.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-pm6jo2B4.js → LockedThumbnail-Drsh4B5o.js} +8 -8
  16. package/dist/{LockedThumbnail-pm6jo2B4.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-BCbVXFHI.js +4698 -0
  19. package/dist/index-BCbVXFHI.js.map +1 -0
  20. package/dist/index-CQ913euH.cjs +2 -0
  21. package/dist/index-CQ913euH.cjs.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +22 -13
  24. package/dist/index.js +1 -1
  25. package/package.json +1 -1
  26. package/src/components/ChannelView.tsx +2 -8
  27. package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -140
  28. package/src/components/CustomMessage/index.tsx +15 -20
  29. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
  30. package/src/components/MessageAttachment/Image/index.tsx +7 -1
  31. package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
  32. package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
  33. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
  34. package/src/components/MessageAttachment/Video/index.tsx +11 -2
  35. package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
  36. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
  37. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
  41. package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
  42. package/src/components/MessageAttachment/index.tsx +18 -9
  43. package/src/components/MessageAttachment/types.ts +1 -1
  44. package/src/styles.css +177 -85
  45. package/dist/index-7sLuX6s4.cjs +0 -18
  46. package/dist/index-7sLuX6s4.cjs.map +0 -1
  47. package/dist/index-Co-LV7yc.js +0 -8220
  48. package/dist/index-Co-LV7yc.js.map +0 -1
  49. package/src/components/CustomMessage/CustomMessageActions.tsx +0 -35
@@ -17,6 +17,21 @@ beforeEach(() => {
17
17
  mockTriggerDownload.mockClear()
18
18
  })
19
19
 
20
+ // The viewer's `<dialog>` stays mounted across open/close so the
21
+ // platform open / close transitions (via `@starting-style` +
22
+ // `allow-discrete`) can play. That means we can't gate openness on
23
+ // "is the dialog in the DOM" — we have to look at the `[open]`
24
+ // attribute the platform sets on a `<dialog>` while it's visible.
25
+ const expectViewerClosed = (testId: string) => {
26
+ const dialog = screen.queryByTestId(testId)
27
+ if (dialog) expect(dialog).not.toHaveAttribute('open')
28
+ }
29
+ const expectViewerOpen = (testId: string) => {
30
+ const dialog = screen.getByTestId(testId)
31
+ expect(dialog).toHaveAttribute('open')
32
+ return dialog
33
+ }
34
+
20
35
  describe('MessageAttachment.Image', () => {
21
36
  it('renders an image bubble with caption', () => {
22
37
  renderWithProviders(
@@ -43,11 +58,11 @@ describe('MessageAttachment.Image', () => {
43
58
  />
44
59
  )
45
60
 
46
- expect(screen.queryByTestId('image-viewer')).toBeNull()
61
+ expectViewerClosed('image-viewer')
47
62
 
48
63
  fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
49
64
 
50
- expect(screen.getByTestId('image-viewer')).toBeInTheDocument()
65
+ expectViewerOpen('image-viewer')
51
66
  })
52
67
 
53
68
  it('renders the dismiss button only on Composer state', () => {
@@ -81,6 +96,104 @@ describe('MessageAttachment.Image', () => {
81
96
  expect(screen.getByLabelText('Open image 3 of 3')).toBeInTheDocument()
82
97
  })
83
98
 
99
+ it('renders a download button inside the image viewer', () => {
100
+ renderWithProviders(
101
+ <MessageAttachment.Image.Received
102
+ src="https://cdn.example.com/photo.jpg"
103
+ alt="Photo"
104
+ filename="photo.jpg"
105
+ />
106
+ )
107
+ fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
108
+ const viewer = expectViewerOpen('image-viewer')
109
+ // The viewer chrome carries its own download action — separate
110
+ // from any sibling download button on the bubble surface — so the
111
+ // user can grab the image without leaving the lightbox.
112
+ expect(
113
+ viewer.querySelector('button[aria-label="Download photo.jpg"]')
114
+ ).not.toBeNull()
115
+ })
116
+
117
+ it('does not render carousel nav controls for a single image', () => {
118
+ renderWithProviders(
119
+ <MessageAttachment.Image.Received
120
+ src="https://cdn.example.com/photo.jpg"
121
+ filename="photo.jpg"
122
+ />
123
+ )
124
+ fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
125
+ expectViewerOpen('image-viewer')
126
+ expect(screen.queryByLabelText('Next image')).toBeNull()
127
+ expect(screen.queryByLabelText('Previous image')).toBeNull()
128
+ })
129
+
130
+ it('renders the stacked images as a carousel inside the viewer', () => {
131
+ renderWithProviders(
132
+ <MessageAttachment.Image.Sent
133
+ filename="album"
134
+ items={[
135
+ { src: 'https://cdn.example.com/a.jpg', alt: 'A' },
136
+ { src: 'https://cdn.example.com/b.jpg', alt: 'B' },
137
+ { src: 'https://cdn.example.com/c.jpg', alt: 'C' },
138
+ ]}
139
+ />
140
+ )
141
+
142
+ // Open the second tile — the viewer should land on image 2.
143
+ fireEvent.click(screen.getByLabelText('Open image 2 of 3'))
144
+ const viewer = expectViewerOpen('image-viewer')
145
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
146
+ 'https://cdn.example.com/b.jpg'
147
+ )
148
+ // Counter + nav chrome only appear on multi-item viewers.
149
+ expect(viewer.textContent).toContain('2 / 3')
150
+
151
+ // Next button advances by one.
152
+ fireEvent.click(screen.getByLabelText('Next image'))
153
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
154
+ 'https://cdn.example.com/c.jpg'
155
+ )
156
+ expect(viewer.textContent).toContain('3 / 3')
157
+
158
+ // Next wraps from the last item back to the first.
159
+ fireEvent.click(screen.getByLabelText('Next image'))
160
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
161
+ 'https://cdn.example.com/a.jpg'
162
+ )
163
+
164
+ // Previous wraps from the first item to the last.
165
+ fireEvent.click(screen.getByLabelText('Previous image'))
166
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
167
+ 'https://cdn.example.com/c.jpg'
168
+ )
169
+ })
170
+
171
+ it('uses ArrowRight / ArrowLeft to navigate stacked images', () => {
172
+ renderWithProviders(
173
+ <MessageAttachment.Image.Sent
174
+ items={[
175
+ { src: 'https://cdn.example.com/a.jpg', alt: 'A' },
176
+ { src: 'https://cdn.example.com/b.jpg', alt: 'B' },
177
+ ]}
178
+ />
179
+ )
180
+ fireEvent.click(screen.getByLabelText('Open image 1 of 2'))
181
+ const viewer = expectViewerOpen('image-viewer')
182
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
183
+ 'https://cdn.example.com/a.jpg'
184
+ )
185
+
186
+ fireEvent.keyDown(window, { key: 'ArrowRight' })
187
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
188
+ 'https://cdn.example.com/b.jpg'
189
+ )
190
+
191
+ fireEvent.keyDown(window, { key: 'ArrowLeft' })
192
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
193
+ 'https://cdn.example.com/a.jpg'
194
+ )
195
+ })
196
+
84
197
  it('shows a +N overflow tile for 5+ items', () => {
85
198
  renderWithProviders(
86
199
  <MessageAttachment.Image.Sent
@@ -113,9 +226,9 @@ describe('MessageAttachment.Pdf', () => {
113
226
  filename="doc.pdf"
114
227
  />
115
228
  )
116
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
229
+ expectViewerClosed('pdf-viewer')
117
230
  fireEvent.click(screen.getByLabelText('Open doc.pdf'))
118
- expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
231
+ expectViewerOpen('pdf-viewer')
119
232
  })
120
233
 
121
234
  it('forwards the onClick handler before opening the viewer', () => {
@@ -167,11 +280,42 @@ describe('MessageAttachment.Pdf', () => {
167
280
  ]}
168
281
  />
169
282
  )
170
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
283
+ expectViewerClosed('pdf-viewer')
171
284
  fireEvent.click(screen.getByLabelText('Open b.pdf'))
172
- const viewer = screen.getByTestId('pdf-viewer')
173
- expect(viewer).toBeInTheDocument()
174
- expect(viewer.textContent).toContain('b.pdf')
285
+ const viewer = expectViewerOpen('pdf-viewer')
286
+ // The active filename surfaces as the dialog's accessible name —
287
+ // the lightbox chrome is intentionally just close + carousel chrome
288
+ // so the filename isn't rendered as visible text inside the modal.
289
+ expect(viewer.getAttribute('aria-label')).toBe('b.pdf')
290
+ // Carousel chrome is present because items.length > 1.
291
+ expect(screen.getByLabelText('Next document')).toBeInTheDocument()
292
+ expect(screen.getByLabelText('Previous document')).toBeInTheDocument()
293
+ expect(viewer.textContent).toContain('2 / 2')
294
+ })
295
+
296
+ it('navigates between stacked PDFs via the carousel controls', () => {
297
+ renderWithProviders(
298
+ <MessageAttachment.Pdf.Received
299
+ items={[
300
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
301
+ { src: 'https://cdn.example.com/b.pdf', filename: 'b.pdf' },
302
+ { src: 'https://cdn.example.com/c.pdf', filename: 'c.pdf' },
303
+ ]}
304
+ />
305
+ )
306
+ fireEvent.click(screen.getByLabelText('Open a.pdf'))
307
+ const viewer = expectViewerOpen('pdf-viewer')
308
+ expect(viewer.getAttribute('aria-label')).toBe('a.pdf')
309
+
310
+ fireEvent.click(screen.getByLabelText('Next document'))
311
+ expect(viewer.getAttribute('aria-label')).toBe('b.pdf')
312
+
313
+ fireEvent.click(screen.getByLabelText('Next document'))
314
+ expect(viewer.getAttribute('aria-label')).toBe('c.pdf')
315
+
316
+ // Wraps back to the start.
317
+ fireEvent.click(screen.getByLabelText('Next document'))
318
+ expect(viewer.getAttribute('aria-label')).toBe('a.pdf')
175
319
  })
176
320
 
177
321
  it('renders a sibling Download button on Sent / Received rows', () => {
@@ -214,11 +358,11 @@ describe('MessageAttachment.Pdf', () => {
214
358
  filename="notes.pdf"
215
359
  />
216
360
  )
217
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
361
+ expectViewerClosed('pdf-viewer')
218
362
  fireEvent.click(screen.getByLabelText('Download notes.pdf'))
219
363
  // Download should fire on its own without bubbling up to the row
220
364
  // activation that opens the viewer.
221
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
365
+ expectViewerClosed('pdf-viewer')
222
366
  })
223
367
 
224
368
  it('hides the Download button on the Composer state (dismiss takes its place)', () => {
@@ -249,7 +393,7 @@ describe('MessageAttachment.Pdf', () => {
249
393
  />
250
394
  )
251
395
  fireEvent.click(screen.getByLabelText('Open c.pdf'))
252
- expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
396
+ expectViewerOpen('pdf-viewer')
253
397
 
254
398
  expect(() =>
255
399
  rerender(
@@ -263,8 +407,12 @@ describe('MessageAttachment.Pdf', () => {
263
407
 
264
408
  // Bubble still renders the surviving row instead of blowing up.
265
409
  expect(screen.getByLabelText('Open a.pdf')).toBeInTheDocument()
266
- // Viewer falls back to the last available item (the surviving one).
267
- expect(screen.getByTestId('pdf-viewer').textContent).toContain('a.pdf')
410
+ // Viewer falls back to the last available item (the surviving one)
411
+ // — surfaced as the dialog's aria-label since the chrome no longer
412
+ // renders the filename as visible text.
413
+ expect(screen.getByTestId('pdf-viewer').getAttribute('aria-label')).toBe(
414
+ 'a.pdf'
415
+ )
268
416
  })
269
417
 
270
418
  it('does not open the viewer when onClick returns false', () => {
@@ -278,7 +426,7 @@ describe('MessageAttachment.Pdf', () => {
278
426
  )
279
427
  fireEvent.click(screen.getByLabelText('Open doc.pdf'))
280
428
  expect(onClick).toHaveBeenCalledTimes(1)
281
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
429
+ expectViewerClosed('pdf-viewer')
282
430
  })
283
431
  })
284
432
 
@@ -450,9 +598,41 @@ describe('MessageAttachment.Video', () => {
450
598
  filename="clip.mp4"
451
599
  />
452
600
  )
453
- expect(screen.queryByTestId('video-viewer')).toBeNull()
601
+ expectViewerClosed('video-viewer')
454
602
  fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
455
- expect(screen.getByTestId('video-viewer')).toBeInTheDocument()
603
+ expectViewerOpen('video-viewer')
604
+ // Single-item viewer has no carousel chrome.
605
+ expect(screen.queryByLabelText('Next video')).toBeNull()
606
+ })
607
+
608
+ it('navigates between stacked videos via the carousel controls', () => {
609
+ renderWithProviders(
610
+ <MessageAttachment.Video.Sent
611
+ filename="clip"
612
+ items={[
613
+ {
614
+ src: 'https://cdn.example.com/a.mp4',
615
+ poster: 'https://cdn.example.com/a.jpg',
616
+ mimeType: 'video/mp4',
617
+ },
618
+ {
619
+ src: 'https://cdn.example.com/b.mp4',
620
+ poster: 'https://cdn.example.com/b.jpg',
621
+ mimeType: 'video/mp4',
622
+ },
623
+ ]}
624
+ />
625
+ )
626
+ fireEvent.click(screen.getByLabelText('Play video 1 of 2'))
627
+ const viewer = expectViewerOpen('video-viewer')
628
+ expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
629
+ 'https://cdn.example.com/a.mp4'
630
+ )
631
+
632
+ fireEvent.click(screen.getByLabelText('Next video'))
633
+ expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
634
+ 'https://cdn.example.com/b.mp4'
635
+ )
456
636
  })
457
637
  })
458
638
 
@@ -485,13 +665,14 @@ describe('ViewerShell focus management', () => {
485
665
  expect(document.activeElement).toBe(opener)
486
666
 
487
667
  fireEvent.click(opener)
488
- expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
668
+ expectViewerOpen('pdf-viewer')
489
669
 
490
670
  fireEvent.click(screen.getByLabelText('Close viewer'))
491
671
 
492
672
  // After close, focus should hop back to whatever was focused
493
- // before the viewer mounted (the row's Open button).
494
- expect(screen.queryByTestId('pdf-viewer')).toBeNull()
673
+ // before the viewer mounted (the row's Open button), and the
674
+ // dialog should no longer carry the `[open]` attribute.
675
+ expectViewerClosed('pdf-viewer')
495
676
  expect(document.activeElement).toBe(opener)
496
677
  })
497
678
  })
@@ -5,7 +5,7 @@ import CompactDocumentRow from '../_shared/CompactDocumentRow'
5
5
  import DismissButton from '../_shared/DismissButton'
6
6
  import DownloadAction from '../_shared/DownloadAction'
7
7
  import { filenameFromUrl } from '../_shared/fileMeta'
8
- import PdfViewer from '../_shared/PdfViewer'
8
+ import PdfViewer, { type PdfViewerItem } from '../_shared/PdfViewer'
9
9
  import { useViewer } from '../_shared/useViewer'
10
10
  import {
11
11
  bubbleVariantForState,
@@ -19,7 +19,7 @@ import {
19
19
  export interface PdfAttachmentSharedProps extends MessageAttachmentBaseProps {
20
20
  /** Single PDF — convenience for the most common case. */
21
21
  src?: string
22
- /** Filename — drives the title + the meta line + the viewer toolbar. */
22
+ /** Filename — drives the row title + the meta line + the viewer dialog's accessible name. */
23
23
  filename?: string
24
24
  /** File size in bytes — formatted into the `EXT · SIZE` meta line. */
25
25
  fileSize?: number
@@ -128,15 +128,13 @@ const PdfAttachmentRow: React.FC<InternalPdfRowProps> = ({
128
128
  return null
129
129
  }
130
130
 
131
- // Clamp the viewer index defensively: if `items` shrinks between
132
- // the open click and the next render (server push, optimistic
133
- // update, etc.) `viewerIndex` may point past the new array.
134
- // Dereferencing `undefined` would crash the whole bubble, so fall
135
- // back to the last available row instead.
136
- const safeIndex = Math.min(viewerIndex, resolvedItems.length - 1)
137
- const activeItem = resolvedItems[safeIndex]
138
- const activeFilename =
139
- activeItem.filename ?? filenameFromUrl(activeItem.src)
131
+ // Project the bubble's `PdfItem`s onto the viewer's carousel-aware
132
+ // shape. The viewer clamps its own active index defensively, so we
133
+ // don't need to second-guess `viewerIndex` here.
134
+ const viewerItems: PdfViewerItem[] = resolvedItems.map((item) => ({
135
+ src: item.src,
136
+ filename: item.filename ?? filenameFromUrl(item.src),
137
+ }))
140
138
 
141
139
  return (
142
140
  <Bubble
@@ -154,8 +152,9 @@ const PdfAttachmentRow: React.FC<InternalPdfRowProps> = ({
154
152
  // Composer only supports a single attachment so the dismiss
155
153
  // control sits on the only row. Sent / Received rows expose
156
154
  // a download icon button so the user can grab the PDF
157
- // without opening the viewer first (the viewer also has a
158
- // download in its toolbar this is the quick-grab shortcut).
155
+ // without opening the viewer first the viewer dialog is
156
+ // intentionally just the document + a close button, so this
157
+ // sibling action is the only one-click download surface.
159
158
  const trailingAction =
160
159
  showDismiss && index === 0 ? (
161
160
  <DismissButton onClick={onDismiss!} variant="inline" />
@@ -183,8 +182,8 @@ const PdfAttachmentRow: React.FC<InternalPdfRowProps> = ({
183
182
 
184
183
  <PdfViewer
185
184
  open={viewerOpen}
186
- src={activeItem.src}
187
- filename={activeFilename}
185
+ items={viewerItems}
186
+ initialIndex={viewerIndex}
188
187
  onClose={closeViewer}
189
188
  />
190
189
  </Bubble>
@@ -53,13 +53,13 @@ const handleDismiss = () => {
53
53
  /**
54
54
  * Single video — the bubble shows the poster with a play overlay.
55
55
  * Clicking opens the full-viewport `VideoViewer` with native controls
56
- * (play / pause / seek / fullscreen / volume) and a download action.
56
+ * (play / pause / seek / fullscreen / volume / download).
57
57
  */
58
58
  export const Single: StoryFn = () => (
59
59
  <StoryPage>
60
60
  <StoryHeading
61
61
  title="Single video"
62
- description="Click any bubble to open the full-screen video viewer with native controls and a download action."
62
+ description="Click any bubble to open the full-screen video viewer with the browser's native controls (play / pause / seek / fullscreen / volume / download)."
63
63
  />
64
64
  <StoryGrid>
65
65
  <StoryRow
@@ -23,7 +23,7 @@ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
23
23
  poster?: string
24
24
  /** MIME type hint — typed onto the inline `<source>` element. */
25
25
  mimeType?: string
26
- /** Filename surfaced in the viewer toolbar + download default name. */
26
+ /** Filename used as the viewer dialog's accessible name. */
27
27
  filename?: string
28
28
  /**
29
29
  * Stacked videos. Takes precedence over `src` when set. Renders a
@@ -121,6 +121,12 @@ interface InternalVideoRowProps extends VideoAttachmentSharedProps {
121
121
  state: MessageAttachmentState
122
122
  }
123
123
 
124
+ /**
125
+ * Project the bubble's `VideoItem`s onto the carousel-aware viewer's
126
+ * `VideoViewerItem` shape. When a shared outer `filename` is supplied
127
+ * for a stacked bubble, suffix `(N)` per sibling so each carousel page
128
+ * still gets a distinct accessible name.
129
+ */
124
130
  const buildViewerItems = (
125
131
  resolvedItems: VideoItem[],
126
132
  filename?: string
@@ -177,7 +183,10 @@ const VideoComposerInner: React.FC<{
177
183
 
178
184
  <VideoViewer
179
185
  open={viewerOpen}
180
- items={buildViewerItems([{ src, poster, mimeType, preload }], filename)}
186
+ items={buildViewerItems(
187
+ [{ src, poster, mimeType, preload }],
188
+ filename
189
+ )}
181
190
  initialIndex={viewerIndex}
182
191
  onClose={closeViewer}
183
192
  />
@@ -0,0 +1,47 @@
1
+ import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ export interface CarouselNavProps {
5
+ onPrev: () => void
6
+ onNext: () => void
7
+ /** Accessible label for the previous-item button. */
8
+ prevLabel?: string
9
+ /** Accessible label for the next-item button. */
10
+ nextLabel?: string
11
+ }
12
+
13
+ /**
14
+ * Prev / next chrome shared by every carousel-aware viewer. Rendered
15
+ * inside the dialog body so the buttons sit absolutely positioned
16
+ * against the viewer (`.mes-media-viewer__nav--{prev,next}`).
17
+ *
18
+ * Styling lives as plain CSS in `styles.css` so the buttons render
19
+ * correctly regardless of whether the consumer has Tailwind wired up.
20
+ */
21
+ const CarouselNav: React.FC<CarouselNavProps> = ({
22
+ onPrev,
23
+ onNext,
24
+ prevLabel = 'Previous',
25
+ nextLabel = 'Next',
26
+ }) => (
27
+ <>
28
+ <button
29
+ type="button"
30
+ onClick={onPrev}
31
+ aria-label={prevLabel}
32
+ className="mes-media-viewer__nav mes-media-viewer__nav--prev"
33
+ >
34
+ <CaretLeftIcon size={20} weight="bold" aria-hidden />
35
+ </button>
36
+ <button
37
+ type="button"
38
+ onClick={onNext}
39
+ aria-label={nextLabel}
40
+ className="mes-media-viewer__nav mes-media-viewer__nav--next"
41
+ >
42
+ <CaretRightIcon size={20} weight="bold" aria-hidden />
43
+ </button>
44
+ </>
45
+ )
46
+
47
+ export default CarouselNav
@@ -11,16 +11,20 @@ import { triggerDownload } from './triggerDownload'
11
11
  export type DownloadActionVariant =
12
12
  /** Solid pill button used inside compact / file rows. */
13
13
  | 'pill'
14
- /** Round translucent overlay button used over media (image / video viewers). */
15
- | 'overlay'
16
- /** Flat icon button used in viewer toolbars. */
17
- | 'toolbar'
18
14
  /**
19
15
  * Compact round icon button sized for the trailing slot of a
20
16
  * `CompactDocumentRow` (PDF / File rows). Adopts the row's tone so
21
17
  * it sits inline next to the filename without competing with it.
22
18
  */
23
19
  | 'inline'
20
+ /**
21
+ * Round icon button sized to match the close action in the
22
+ * `ViewerShell` chrome — used by `ImageViewer` for in-viewer
23
+ * downloads. Styled with plain CSS (`.mes-media-viewer__action`) so
24
+ * it renders correctly without a Tailwind dependency in the
25
+ * consumer's app.
26
+ */
27
+ | 'viewer'
24
28
 
25
29
  export interface DownloadActionProps {
26
30
  url: string
@@ -28,7 +32,8 @@ export interface DownloadActionProps {
28
32
  variant?: DownloadActionVariant
29
33
  /**
30
34
  * Override the visible label on `pill` variants. Defaults to
31
- * `'Download'`. Hidden on `overlay` / `toolbar` variants.
35
+ * `'Download'`. On `inline` / `viewer` variants the label is
36
+ * hidden visually and surfaced as the button's `aria-label`.
32
37
  */
33
38
  label?: string
34
39
  /** Hide the label, keeping just the icon. Defaults to `true` for non-pill variants. */
@@ -117,57 +122,52 @@ const DownloadAction: React.FC<DownloadActionProps> = ({
117
122
  )
118
123
  }
119
124
 
120
- if (variant === 'pill') {
125
+ if (variant === 'viewer') {
126
+ // Styled entirely through `.mes-media-viewer__action` (plain CSS
127
+ // in `styles.css`) so the button renders correctly when consumed
128
+ // outside this repo without a Tailwind setup. Sits next to the
129
+ // close action inside `ViewerShell`'s top-right chrome.
121
130
  return (
122
131
  <button
123
132
  type="button"
124
133
  onClick={handleClick}
125
134
  disabled={busy}
126
- aria-label={showIconOnly ? label : undefined}
127
- className={classNames(
128
- 'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
129
- tone === 'dark'
130
- ? 'bg-[#121110] text-white hover:bg-[#2a2928]'
131
- : 'bg-white text-[#121110] hover:bg-white/90'
132
- )}
135
+ aria-label={label}
136
+ className="mes-media-viewer__action"
133
137
  >
134
138
  {busy ? (
135
- <CircleNotchIcon
136
- className="size-4 animate-spin"
137
- weight="bold"
138
- aria-hidden
139
- />
139
+ <CircleNotchIcon size={20} weight="bold" aria-hidden />
140
140
  ) : (
141
- <DownloadSimpleIcon {...iconProps} aria-hidden />
141
+ <DownloadSimpleIcon size={20} weight="bold" aria-hidden />
142
142
  )}
143
- {showIconOnly ? null : label}
144
143
  </button>
145
144
  )
146
145
  }
147
146
 
148
- // overlay / toolbar round translucent button with just the icon
147
+ // pillonly remaining variant
149
148
  return (
150
149
  <button
151
150
  type="button"
152
151
  onClick={handleClick}
153
152
  disabled={busy}
154
- aria-label={label}
153
+ aria-label={showIconOnly ? label : undefined}
155
154
  className={classNames(
156
- 'flex size-10 shrink-0 items-center justify-center rounded-full text-white transition-colors disabled:opacity-70',
157
- variant === 'overlay'
158
- ? 'bg-black/55 backdrop-blur hover:bg-black/70'
159
- : 'bg-white/10 hover:bg-white/20'
155
+ 'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
156
+ tone === 'dark'
157
+ ? 'bg-[#121110] text-white hover:bg-[#2a2928]'
158
+ : 'bg-white text-[#121110] hover:bg-white/90'
160
159
  )}
161
160
  >
162
161
  {busy ? (
163
162
  <CircleNotchIcon
164
- className="size-5 animate-spin"
163
+ className="size-4 animate-spin"
165
164
  weight="bold"
166
165
  aria-hidden
167
166
  />
168
167
  ) : (
169
168
  <DownloadSimpleIcon {...iconProps} aria-hidden />
170
169
  )}
170
+ {showIconOnly ? null : label}
171
171
  </button>
172
172
  )
173
173
  }