@linktr.ee/messaging-react 2.2.3 → 2.3.0-rc-1779486690

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 (44) hide show
  1. package/dist/{Card-DOws_Rs6.js → Card-4takoN_-.js} +2 -2
  2. package/dist/{Card-DOws_Rs6.js.map → Card-4takoN_-.js.map} +1 -1
  3. package/dist/{Card-Ccy5fPho.js → Card-BuROm0u7.js} +2 -2
  4. package/dist/{Card-Ccy5fPho.js.map → Card-BuROm0u7.js.map} +1 -1
  5. package/dist/{Card-DWH_vCQD.cjs → Card-CexShqpK.cjs} +2 -2
  6. package/dist/{Card-DWH_vCQD.cjs.map → Card-CexShqpK.cjs.map} +1 -1
  7. package/dist/{Card-CyszwESe.cjs → Card-CgpHBx-W.cjs} +2 -2
  8. package/dist/{Card-CyszwESe.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
  9. package/dist/{Card-BRub_HpW.js → Card-DdpdnSh_.js} +3 -3
  10. package/dist/{Card-BRub_HpW.js.map → Card-DdpdnSh_.js.map} +1 -1
  11. package/dist/{Card-imQIyJzJ.cjs → Card-ot16XqS2.cjs} +2 -2
  12. package/dist/{Card-imQIyJzJ.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-D512VE6T.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
  14. package/dist/{LockedThumbnail-D512VE6T.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-C9eocsRT.js → LockedThumbnail-Drsh4B5o.js} +2 -2
  16. package/dist/{LockedThumbnail-C9eocsRT.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/{index-uxWUZe1M.js → index-BCbVXFHI.js} +1648 -1740
  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/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
  27. package/src/components/MessageAttachment/Image/index.tsx +7 -1
  28. package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
  29. package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
  31. package/src/components/MessageAttachment/Video/index.tsx +11 -2
  32. package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
  33. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
  34. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
  35. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
  36. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
  37. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
  38. package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
  39. package/src/components/MessageAttachment/index.tsx +18 -9
  40. package/src/components/MessageAttachment/types.ts +1 -1
  41. package/src/styles.css +158 -1
  42. package/dist/index-IlgylJT2.cjs +0 -2
  43. package/dist/index-IlgylJT2.cjs.map +0 -1
  44. package/dist/index-uxWUZe1M.js.map +0 -1
@@ -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
  }
@@ -1,28 +1,18 @@
1
- import {
2
- CaretLeftIcon,
3
- CaretRightIcon,
4
- MagnifyingGlassMinusIcon,
5
- MagnifyingGlassPlusIcon,
6
- } from '@phosphor-icons/react'
7
- import classNames from 'classnames'
8
- import React, {
9
- useCallback,
10
- useEffect,
11
- useMemo,
12
- useRef,
13
- useState,
14
- } from 'react'
1
+ import React, { useMemo } from 'react'
15
2
 
3
+ import CarouselNav from './CarouselNav'
16
4
  import DownloadAction from './DownloadAction'
17
5
  import { filenameFromUrl } from './fileMeta'
6
+ import { useCarousel } from './useCarousel'
18
7
  import ViewerShell from './ViewerShell'
19
8
 
20
9
  export interface ImageViewerItem {
21
10
  src: string
22
11
  alt?: string
23
12
  /**
24
- * Filename used by the download action. Falls back to the last
25
- * pathname segment of `src` when omitted.
13
+ * Filename used by the download action and as the dialog's
14
+ * accessible name. Falls back to the last pathname segment of
15
+ * `src` when omitted.
26
16
  */
27
17
  filename?: string
28
18
  }
@@ -30,34 +20,27 @@ export interface ImageViewerItem {
30
20
  export interface ImageViewerProps {
31
21
  open: boolean
32
22
  items: ImageViewerItem[]
33
- /** Currently active item. Defaults to `0`. */
23
+ /** Index to display when the viewer first opens. Defaults to `0`. */
34
24
  initialIndex?: number
35
25
  onClose: () => void
36
26
  }
37
27
 
38
- const MIN_SCALE = 1
39
- const MAX_SCALE = 8
40
- const ZOOM_STEP = 1.25
41
-
42
- const clamp = (value: number, min: number, max: number) =>
43
- Math.min(Math.max(value, min), max)
44
-
45
- interface ViewportState {
46
- scale: number
47
- x: number
48
- y: number
49
- }
50
-
51
- const RESET_STATE: ViewportState = { scale: 1, x: 0, y: 0 }
52
-
53
28
  /**
54
- * Native lightbox-style image viewer with mouse-wheel zoom, drag-to-pan,
55
- * double-click toggle (1× 2×), keyboard arrow navigation between
56
- * stacked items, and built-in download.
29
+ * Native lightbox-style image viewer full-viewport `<dialog>` with
30
+ * the image centered at reasonable max dimensions, a single close
31
+ * button, and a download action in the chrome.
32
+ *
33
+ * When `items.length > 1` the viewer becomes a carousel:
57
34
  *
58
- * Used by every `MessageAttachment.Image.*` variant Composer / Sent /
59
- * Received all open this viewer when activated, so admins can preview
60
- * attachments before sending and recipients can preview after receipt.
35
+ * - Prev / next on-screen controls hug the viewport sides.
36
+ * - `ArrowLeft` / `ArrowRight` step between siblings (the keyboard
37
+ * listener is parked while a `<video>` / `<audio>` element is
38
+ * focused so native media seek still works in mixed contexts).
39
+ * - A `current / total` counter sits in the top-left chrome.
40
+ *
41
+ * Used by every `MessageAttachment.Image.*` variant
42
+ * (Composer / Sent / Received) so admins can preview attachments
43
+ * before sending and recipients can preview after receipt.
61
44
  */
62
45
  const ImageViewer: React.FC<ImageViewerProps> = ({
63
46
  open,
@@ -65,247 +48,62 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
65
48
  initialIndex = 0,
66
49
  onClose,
67
50
  }) => {
68
- const safeIndex = clamp(initialIndex, 0, Math.max(items.length - 1, 0))
69
- const [index, setIndex] = useState(safeIndex)
70
- const [view, setView] = useState<ViewportState>(RESET_STATE)
71
- const [dragging, setDragging] = useState(false)
72
- const dragStart = useRef<{
73
- x: number
74
- y: number
75
- panX: number
76
- panY: number
77
- } | null>(null)
78
- const stageRef = useRef<HTMLDivElement | null>(null)
79
-
80
- // Reset zoom whenever the viewer opens or the active item changes.
81
- useEffect(() => {
82
- if (!open) return
83
- setIndex(clamp(initialIndex, 0, Math.max(items.length - 1, 0)))
84
- setView(RESET_STATE)
85
- }, [open, initialIndex, items.length])
86
-
87
- useEffect(() => {
88
- setView(RESET_STATE)
89
- }, [index])
90
-
91
- const goPrev = useCallback(() => {
92
- setIndex((i) => (i <= 0 ? items.length - 1 : i - 1))
93
- }, [items.length])
94
-
95
- const goNext = useCallback(() => {
96
- setIndex((i) => (i >= items.length - 1 ? 0 : i + 1))
97
- }, [items.length])
98
-
99
- // Arrow-key navigation between items in the stack.
100
- useEffect(() => {
101
- if (!open) return undefined
102
- const onKey = (e: KeyboardEvent) => {
103
- if (items.length <= 1) return
104
- if (e.key === 'ArrowRight') {
105
- e.preventDefault()
106
- goNext()
107
- } else if (e.key === 'ArrowLeft') {
108
- e.preventDefault()
109
- goPrev()
110
- }
111
- }
112
- window.addEventListener('keydown', onKey)
113
- return () => window.removeEventListener('keydown', onKey)
114
- }, [open, items.length, goPrev, goNext])
115
-
116
- const zoomBy = useCallback((factor: number) => {
117
- setView((prev) => {
118
- const nextScale = clamp(prev.scale * factor, MIN_SCALE, MAX_SCALE)
119
- // Re-center when we hit 1× so the image is never offset off-screen.
120
- if (nextScale === MIN_SCALE) return RESET_STATE
121
- return { scale: nextScale, x: prev.x, y: prev.y }
122
- })
123
- }, [])
124
-
125
- // React's synthetic `onWheel` is registered as a passive listener
126
- // (since React 17), so `e.preventDefault()` is a no-op inside an
127
- // `onWheel` handler — the page scrolls behind the viewer regardless.
128
- // Attach the listener manually on the stage element with
129
- // `{ passive: false }` so we can actually stop the page scroll
130
- // while the user zooms.
131
- useEffect(() => {
132
- if (!open) return undefined
133
- const stage = stageRef.current
134
- if (!stage) return undefined
135
-
136
- const handleWheel = (e: WheelEvent) => {
137
- e.preventDefault()
138
- const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
139
- zoomBy(factor)
140
- }
141
-
142
- stage.addEventListener('wheel', handleWheel, { passive: false })
143
- return () => stage.removeEventListener('wheel', handleWheel)
144
- }, [open, zoomBy])
145
-
146
- const handleDoubleClick = useCallback(() => {
147
- setView((prev) =>
148
- prev.scale > 1
149
- ? RESET_STATE
150
- : { scale: 2, x: prev.x, y: prev.y }
151
- )
152
- }, [])
153
-
154
- const handleMouseDown = useCallback(
155
- (e: React.MouseEvent<HTMLDivElement>) => {
156
- // Only allow drag-pan once the user has zoomed in.
157
- if (view.scale <= 1) return
158
- e.preventDefault()
159
- setDragging(true)
160
- dragStart.current = {
161
- x: e.clientX,
162
- y: e.clientY,
163
- panX: view.x,
164
- panY: view.y,
165
- }
166
- },
167
- [view.scale, view.x, view.y]
168
- )
169
-
170
- useEffect(() => {
171
- if (!dragging) return undefined
172
-
173
- const onMove = (e: MouseEvent) => {
174
- const start = dragStart.current
175
- if (!start) return
176
- setView((prev) => ({
177
- scale: prev.scale,
178
- x: start.panX + (e.clientX - start.x),
179
- y: start.panY + (e.clientY - start.y),
180
- }))
181
- }
182
- const onUp = () => {
183
- dragStart.current = null
184
- setDragging(false)
185
- }
186
- window.addEventListener('mousemove', onMove)
187
- window.addEventListener('mouseup', onUp)
188
- return () => {
189
- window.removeEventListener('mousemove', onMove)
190
- window.removeEventListener('mouseup', onUp)
191
- }
192
- }, [dragging])
51
+ const { index, prev, next } = useCarousel({
52
+ length: items.length,
53
+ initialIndex,
54
+ open,
55
+ })
193
56
 
194
57
  const item = items[index]
195
58
  const filename = useMemo(
196
- () =>
197
- item?.filename ?? (item ? filenameFromUrl(item.src) : 'image'),
59
+ () => item?.filename ?? (item ? filenameFromUrl(item.src) : 'image'),
198
60
  [item]
199
61
  )
200
62
 
201
- const cursorClass = useMemo(() => {
202
- if (view.scale <= 1) return 'cursor-zoom-in'
203
- return dragging ? 'cursor-grabbing' : 'cursor-grab'
204
- }, [view.scale, dragging])
205
-
206
63
  if (!item) return null
207
64
 
208
- const totalLabel = items.length > 1 ? ` (${index + 1} / ${items.length})` : ''
209
-
210
65
  return (
211
66
  <ViewerShell
212
67
  open={open}
213
68
  onClose={onClose}
214
- title={`${filename}${totalLabel}`}
69
+ ariaLabel={filename}
70
+ counter={
71
+ items.length > 1 ? `${index + 1} / ${items.length}` : undefined
72
+ }
215
73
  actions={
216
- <>
217
- <button
218
- type="button"
219
- onClick={() => zoomBy(1 / ZOOM_STEP)}
220
- disabled={view.scale <= MIN_SCALE}
221
- aria-label="Zoom out"
222
- className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 disabled:opacity-40"
223
- >
224
- <MagnifyingGlassMinusIcon
225
- className="size-5"
226
- weight="bold"
227
- aria-hidden
228
- />
229
- </button>
230
- <button
231
- type="button"
232
- onClick={() => zoomBy(ZOOM_STEP)}
233
- disabled={view.scale >= MAX_SCALE}
234
- aria-label="Zoom in"
235
- className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 disabled:opacity-40"
236
- >
237
- <MagnifyingGlassPlusIcon
238
- className="size-5"
239
- weight="bold"
240
- aria-hidden
241
- />
242
- </button>
243
- <DownloadAction
244
- url={item.src}
245
- filename={filename}
246
- variant="overlay"
247
- label={`Download ${filename}`}
248
- />
249
- </>
74
+ <DownloadAction
75
+ url={item.src}
76
+ filename={filename}
77
+ variant="viewer"
78
+ label={`Download ${filename}`}
79
+ />
250
80
  }
251
81
  data-testid="image-viewer"
252
82
  >
253
- <div
254
- ref={stageRef}
255
- // The stage swallows wheel / double-click / drag gestures to
256
- // implement the zoom + pan controls. It's not a button (clicks
257
- // don't have a single discrete action), so suppress the
258
- // a11y/no-static-element-interactions rule here — the discrete
259
- // zoom / download actions live in the toolbar above. The wheel
260
- // listener is attached manually (non-passive) in the effect
261
- // above so `preventDefault()` actually stops page scroll.
262
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
263
- role="presentation"
264
- onDoubleClick={handleDoubleClick}
265
- onMouseDown={handleMouseDown}
266
- className={classNames(
267
- 'relative flex h-full w-full select-none items-center justify-center overflow-hidden',
268
- cursorClass
269
- )}
270
- >
271
- <img
272
- src={item.src}
273
- alt={item.alt ?? filename}
274
- draggable={false}
275
- // Once the user has explicitly opened the viewer the active
276
- // image needs to appear immediately, so we eager-load it.
277
- // Adjacent / off-screen viewer images aren't rendered here
278
- // (we only mount `items[index]`), so the lazy default on
279
- // siblings is automatic.
280
- loading="eager"
281
- decoding="async"
282
- style={{
283
- transform: `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`,
284
- transition: dragging ? 'none' : 'transform 120ms ease-out',
285
- }}
286
- className="max-h-full max-w-full object-contain"
287
- />
288
- </div>
83
+ <img
84
+ // Forcing a key swap on item change ensures React replaces the
85
+ // `<img>` cleanly between siblings otherwise the previous
86
+ // image stays painted for a frame while the new `src`
87
+ // decodes, which reads as a stutter at carousel pace.
88
+ key={`${index}:${item.src}`}
89
+ src={item.src}
90
+ alt={item.alt ?? filename}
91
+ draggable={false}
92
+ // The user has explicitly opened the viewer, so we want the
93
+ // active image to appear immediately rather than fall to the
94
+ // browser's lazy-load heuristics.
95
+ loading="eager"
96
+ decoding="async"
97
+ className="mes-media-viewer__image"
98
+ />
289
99
 
290
100
  {items.length > 1 ? (
291
- <>
292
- <button
293
- type="button"
294
- onClick={goPrev}
295
- aria-label="Previous image"
296
- className="absolute left-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
297
- >
298
- <CaretLeftIcon className="size-5" weight="bold" aria-hidden />
299
- </button>
300
- <button
301
- type="button"
302
- onClick={goNext}
303
- aria-label="Next image"
304
- className="absolute right-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
305
- >
306
- <CaretRightIcon className="size-5" weight="bold" aria-hidden />
307
- </button>
308
- </>
101
+ <CarouselNav
102
+ onPrev={prev}
103
+ onNext={next}
104
+ prevLabel="Previous image"
105
+ nextLabel="Next image"
106
+ />
309
107
  ) : null}
310
108
  </ViewerShell>
311
109
  )
@@ -1,67 +1,84 @@
1
1
  import React, { useMemo } from 'react'
2
2
 
3
- import DownloadAction from './DownloadAction'
3
+ import CarouselNav from './CarouselNav'
4
4
  import { filenameFromUrl } from './fileMeta'
5
+ import { useCarousel } from './useCarousel'
5
6
  import ViewerShell from './ViewerShell'
6
7
 
7
- export interface PdfViewerProps {
8
- open: boolean
8
+ export interface PdfViewerItem {
9
9
  /** Source URL of the PDF document. */
10
10
  src: string
11
- /** Filename used by the toolbar title and the download action. */
11
+ /** Filename used as the dialog's accessible name. */
12
12
  filename?: string
13
+ }
14
+
15
+ export interface PdfViewerProps {
16
+ open: boolean
17
+ items: PdfViewerItem[]
18
+ /** Index to display when the viewer first opens. Defaults to `0`. */
19
+ initialIndex?: number
13
20
  onClose: () => void
14
21
  }
15
22
 
16
23
  /**
17
- * Modal PDF viewer that hosts the document inside a sandboxed
18
- * `<iframe>`, leaving native PDF rendering, scroll, and zoom to the
19
- * browser. Adds a thin top toolbar with the filename + a download
20
- * action so users get the same `download / close` affordances as the
21
- * `ImageViewer` and the inline audio / video players.
24
+ * Modal PDF viewer that hosts the active document inside a sandboxed
25
+ * `<iframe>`, leaving native rendering, scroll, search, and the
26
+ * browser's built-in download controls to the browser. The lightbox
27
+ * chrome is intentionally just a close button plus, for stacked
28
+ * attachments, carousel prev/next + counter.
22
29
  */
23
30
  const PdfViewer: React.FC<PdfViewerProps> = ({
24
31
  open,
25
- src,
26
- filename,
32
+ items,
33
+ initialIndex = 0,
27
34
  onClose,
28
35
  }) => {
29
- const resolvedFilename = useMemo(
30
- () => filename ?? filenameFromUrl(src),
31
- [filename, src]
36
+ const { index, prev, next } = useCarousel({
37
+ length: items.length,
38
+ initialIndex,
39
+ open,
40
+ })
41
+
42
+ const item = items[index]
43
+ const filename = useMemo(
44
+ () => item?.filename ?? (item ? filenameFromUrl(item.src) : 'document'),
45
+ [item]
32
46
  )
33
47
 
34
48
  // `#toolbar=0` is honored by Chromium-family browsers and hides the
35
- // built-in PDF toolbar so our own toolbar reads as the single source
36
- // of truth (otherwise the user sees two stacked toolbars). Firefox /
37
- // Safari ignore it and keep their native toolbars, which is fine —
38
- // the user gets a familiar experience either way.
49
+ // built-in PDF toolbar's redundant controls. Firefox / Safari ignore
50
+ // it and keep their native toolbars, which is fine the user gets a
51
+ // familiar experience either way.
39
52
  //
40
53
  // Preserve any existing fragment the caller supplied (e.g.
41
54
  // `#page=3`) by merging our params into it rather than naively
42
55
  // appending a second `#…` segment.
43
- const iframeSrc = useMemo(() => withPdfViewerParams(src), [src])
56
+ const iframeSrc = useMemo(
57
+ () => (item ? withPdfViewerParams(item.src) : undefined),
58
+ [item]
59
+ )
60
+
61
+ if (!item || !iframeSrc) return null
44
62
 
45
63
  return (
46
64
  <ViewerShell
47
65
  open={open}
48
66
  onClose={onClose}
49
- title={resolvedFilename}
50
- actions={
51
- <DownloadAction
52
- url={src}
53
- filename={resolvedFilename}
54
- variant="overlay"
55
- label={`Download ${resolvedFilename}`}
56
- />
67
+ ariaLabel={filename}
68
+ counter={
69
+ items.length > 1 ? `${index + 1} / ${items.length}` : undefined
57
70
  }
58
- contentClassName="bg-[#1f1e1d]"
59
71
  data-testid="pdf-viewer"
60
72
  >
61
73
  <iframe
74
+ // Force a key swap on item change so the iframe remounts a
75
+ // fresh document rather than re-using the previous scroll
76
+ // position / search state when the user navigates between
77
+ // siblings.
78
+ key={`${index}:${item.src}`}
62
79
  src={iframeSrc}
63
- title={resolvedFilename}
64
- className="absolute inset-0 size-full bg-white"
80
+ title={filename}
81
+ className="mes-media-viewer__iframe"
65
82
  // Sandbox the iframe to stop the embedded document from
66
83
  // reaching parent context. We intentionally omit
67
84
  // `allow-same-origin`: even when the PDF host happens to share
@@ -74,6 +91,15 @@ const PdfViewer: React.FC<PdfViewerProps> = ({
74
91
  // new tab.
75
92
  sandbox="allow-scripts allow-forms allow-popups allow-downloads"
76
93
  />
94
+
95
+ {items.length > 1 ? (
96
+ <CarouselNav
97
+ onPrev={prev}
98
+ onNext={next}
99
+ prevLabel="Previous document"
100
+ nextLabel="Next document"
101
+ />
102
+ ) : null}
77
103
  </ViewerShell>
78
104
  )
79
105
  }