import { XIcon } from '@phosphor-icons/react' import React, { useEffect, useRef } from 'react' import { VIEWER_ACTION_CLASS } from './viewerChromeClasses' export interface ViewerShellProps { open: boolean onClose: () => void /** * Accessible label for the dialog — usually the media's filename. We * intentionally don't render a visible title bar (the chrome is a * single close button + optional actions so the media stays the * focus), so this is the only way assistive tech gets a name for * the modal. */ ariaLabel?: string /** * Optional counter rendered in the top-left chrome (e.g. `2 / 6`). * Carousel-aware viewers set this when their items array has more * than one entry; omit it for single-item viewers. */ counter?: React.ReactNode /** * Extra buttons rendered alongside the close button in the top-right * chrome (e.g. a download action), laid out as one consistent button group. */ actions?: React.ReactNode children: React.ReactNode 'data-testid'?: string } /** * Full-viewport lightbox shell shared by `ImageViewer`, `VideoViewer`, * and `PdfViewer`. Built on a native `` so the package ships * without a Tailwind dependency for the load-bearing chrome: * * - Top-layer + body-scroll lock come for free from `showModal()`. * - `Escape` to close is wired by the platform. * - Focus management (move-in on open, restore on close) is handled * by the browser; we explicitly focus the close button as a * fallback for environments where `showModal` is unavailable * (jsdom under tests). * * The `` root, backdrop, and open/close transitions live in * `styles.css` under `.mes-media-viewer` (they use `::backdrop` / * `@starting-style`, which Tailwind can't express). The chrome inside is * plain Tailwind on the JSX, like the rest of the package. */ const ViewerShell: React.FC = ({ open, onClose, ariaLabel, counter, actions, children, 'data-testid': dataTestId, }) => { const dialogRef = useRef(null) const closeButtonRef = useRef(null) // Drive `` open/close imperatively from the React `open` prop. // We guard `showModal` / `close` because jsdom 23 ships the element // without those methods — falling back to the `[open]` attribute // keeps tests (and any other non-DOM environment) functional even if // the true top-layer modal behavior isn't available there. useEffect(() => { const dialog = dialogRef.current if (!dialog) return undefined if (open) { if (!dialog.open) { if (typeof dialog.showModal === 'function') { try { dialog.showModal() } catch { dialog.setAttribute('open', '') } } else { dialog.setAttribute('open', '') } } const previouslyFocused = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null // Explicitly focus the close button. In a real browser // `showModal()` would already focus the first sequentially // focusable child (which is the close button), but doing it // ourselves guarantees the same behavior in jsdom and in any // browser where focus heuristics differ. closeButtonRef.current?.focus() return () => { if (dialog.open) { if (typeof dialog.close === 'function') { try { dialog.close() } catch { dialog.removeAttribute('open') } } else { dialog.removeAttribute('open') } } if (previouslyFocused && document.body.contains(previouslyFocused)) { previouslyFocused.focus() } } } if (dialog.open) { if (typeof dialog.close === 'function') { try { dialog.close() } catch { dialog.removeAttribute('open') } } else { dialog.removeAttribute('open') } } return undefined }, [open]) // The platform fires `close` when the user dismisses via Escape or // any other native path. Mirror that into React state so the parent's // `open` flag stays in sync — otherwise the dialog would close // visually but our effect would re-open it on the next render. const handleDialogClose = () => { onClose() } // Backdrop click closes the viewer. The click lands on the `` // itself (not its children) because the body sits inside an inner // wrapper — the easiest reliable check. const handleDialogClick = (e: React.MouseEvent) => { if (e.target === dialogRef.current) onClose() } return ( // The native `` element ships its own keyboard dismissal // (Escape, handled by the platform and surfaced through `onClose`), // so the `onClick` on the backdrop doesn't need a paired keyboard // listener — that's the whole point of using ``. The // `jsx-a11y` rules below don't model `` as interactive, so // we silence them locally with the rationale captured here. // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
{counter ? ( {counter} ) : null}
{actions}
{children}
) } export default ViewerShell