@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.
- package/dist/{Card-DKp7ijLV.js → Card-4takoN_-.js} +6 -6
- package/dist/{Card-DKp7ijLV.js.map → Card-4takoN_-.js.map} +1 -1
- package/dist/{Card-Djm6JjNo.js → Card-BuROm0u7.js} +19 -19
- package/dist/{Card-Djm6JjNo.js.map → Card-BuROm0u7.js.map} +1 -1
- package/dist/{Card-BlzGsGam.cjs → Card-CexShqpK.cjs} +2 -2
- package/dist/{Card-BlzGsGam.cjs.map → Card-CexShqpK.cjs.map} +1 -1
- package/dist/{Card-BkWwtS0b.cjs → Card-CgpHBx-W.cjs} +2 -2
- package/dist/{Card-BkWwtS0b.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
- package/dist/{Card-B7yHs01-.js → Card-DdpdnSh_.js} +16 -16
- package/dist/{Card-B7yHs01-.js.map → Card-DdpdnSh_.js.map} +1 -1
- package/dist/{Card-DApWNv5V.cjs → Card-ot16XqS2.cjs} +2 -2
- package/dist/{Card-DApWNv5V.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
- package/dist/{LockedThumbnail-BjF6khtg.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
- package/dist/{LockedThumbnail-BjF6khtg.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
- package/dist/{LockedThumbnail-pm6jo2B4.js → LockedThumbnail-Drsh4B5o.js} +8 -8
- package/dist/{LockedThumbnail-pm6jo2B4.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-BCbVXFHI.js +4698 -0
- package/dist/index-BCbVXFHI.js.map +1 -0
- package/dist/index-CQ913euH.cjs +2 -0
- package/dist/index-CQ913euH.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +22 -13
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.tsx +2 -8
- package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -140
- package/src/components/CustomMessage/index.tsx +15 -20
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
- package/src/components/MessageAttachment/Image/index.tsx +7 -1
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
- package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
- package/src/components/MessageAttachment/Video/index.tsx +11 -2
- package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
- package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
- package/src/components/MessageAttachment/index.tsx +18 -9
- package/src/components/MessageAttachment/types.ts +1 -1
- package/src/styles.css +177 -85
- package/dist/index-7sLuX6s4.cjs +0 -18
- package/dist/index-7sLuX6s4.cjs.map +0 -1
- package/dist/index-Co-LV7yc.js +0 -8220
- package/dist/index-Co-LV7yc.js.map +0 -1
- 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
|
-
|
|
61
|
+
expectViewerClosed('image-viewer')
|
|
47
62
|
|
|
48
63
|
fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
|
|
49
64
|
|
|
50
|
-
|
|
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
|
-
|
|
229
|
+
expectViewerClosed('pdf-viewer')
|
|
117
230
|
fireEvent.click(screen.getByLabelText('Open doc.pdf'))
|
|
118
|
-
|
|
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
|
-
|
|
283
|
+
expectViewerClosed('pdf-viewer')
|
|
171
284
|
fireEvent.click(screen.getByLabelText('Open b.pdf'))
|
|
172
|
-
const viewer =
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
+
expectViewerClosed('video-viewer')
|
|
454
602
|
fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
158
|
-
//
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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'`.
|
|
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 === '
|
|
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={
|
|
127
|
-
className=
|
|
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 {
|
|
141
|
+
<DownloadSimpleIcon size={20} weight="bold" aria-hidden />
|
|
142
142
|
)}
|
|
143
|
-
{showIconOnly ? null : label}
|
|
144
143
|
</button>
|
|
145
144
|
)
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
//
|
|
147
|
+
// pill — only 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
|
|
157
|
-
|
|
158
|
-
? 'bg-
|
|
159
|
-
: 'bg-white
|
|
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-
|
|
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
|
}
|