// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Lightbox.tsx * @input Uses React, native dialog, StyleX, IconButton, theme tokens * @output Exports Lightbox component, LightboxProps, LightboxMedia * @position Core implementation; consumed by index.ts * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Lightbox/Lightbox.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Lightbox/Lightbox.test.tsx (tests for new/changed behavior) * - /packages/core/src/Lightbox/index.ts (exports if types change) * - /packages/core/src/Layer/useLayerDismissal.ts (dismissal stack) * - /apps/storybook/stories/Lightbox.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Lightbox/ (showcase blocks) */ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { Icon } from "../Icon/index.js"; import { IconButton } from "../IconButton/index.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useScrollLock } from "../hooks/useScrollLock.js"; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import { mergeProps, rtlStyles } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { overlayPaddingReset } from "../Layout/padding.stylex.js"; import { LayerDepthProvider } from "../Layer/LayerDepthContext.js"; import { useLayerDismissal } from "../Layer/useLayerDismissal.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; /** * Media type for lightbox items. */ export type LightboxMediaType = 'image' | 'video'; /** * Describes a single media item in a lightbox. */ export interface LightboxMedia { /** Media source URL */ src: string; /** Alt text for accessibility (used as aria-label for video) */ alt: string; /** Optional caption displayed below the media */ caption?: ReactNode; /** * Media type. Zoom/pan is disabled for video. * @default 'image' */ type?: LightboxMediaType; } export interface LightboxProps extends BaseProps { /** Ref forwarded to the root dialog element */ ref?: React.Ref; /** * Whether the lightbox is open. */ isOpen: boolean; /** * Callback when the lightbox open state changes. * Called with `false` on Escape, backdrop click, or close button. */ onOpenChange: (isOpen: boolean) => void; /** * Media to display. Pass a single object for one item, or an array * for gallery mode with prev/next navigation. */ media: LightboxMedia | LightboxMedia[]; /** * Current index in gallery mode (when `media` is an array). * When provided, puts the component in controlled mode. */ index?: number; /** * Initial index in gallery mode for uncontrolled usage. * @default 0 */ defaultIndex?: number; /** * Callback when the gallery index changes via prev/next navigation. */ onIndexChange?: (index: number) => void; /** * Enable zoom on double-click, or Enter/Space/`+`/`-` via keyboard * (images only). When zoomed, drag or use arrow keys to pan. * @default false */ hasZoom?: boolean; /** * Whether video should autoplay when the lightbox opens. * @default false */ hasAutoPlay?: boolean; } // ============================================================================= // Styles // ============================================================================= const styles = { dialog: { kVAEAm: "xixxii4", kpwlN0: "x10a8y8t", kzqmXN: "xn9wirt", kZKoxP: "x1dr59a3", ks0D6T: "x1x1rfll", kskxy: "x7ab17h", kogj98: "x1ghz6dp", kmVPX3: "x1717udv", kWkggS: "xjbqb8w", kVQacm: "xb3r6kr", kI3sdo: "x1a2a7pz", kGyWv1: "xnixb3f", kba3nw: "x1abwkk1", $$css: true as const }, imageWrapper: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kVQacm: "xb3r6kr", kkrTdU: "xt0e3qv", kfSwDN: "x87ps6o", kAzted: "x2lwn1j", $$css: true as const }, imageWrapperZoomable: { kkrTdU: "x1huxd7x x16khyan x2dt3px", $$css: true as const }, imageWrapperZoomed: { kkrTdU: "x1jm3nie x16khyan", $$css: true as const }, imageWrapperDragging: { kkrTdU: "xi9pz9s x16khyan", $$css: true as const }, image: { ks0D6T: "x193iq5w", kskxy: "xmz0i5r", kVIFPx: "x19kjcj4", kfzvcC: "x47corl", k1ekBW: "x11xpdln", kIyJzY: "x13dflua x12w9bfk", kAMwcw: "x9lcvmn", $$css: true as const }, imageDragging: { k1ekBW: "x13b0p5u", $$css: true as const }, closeButton: { kVAEAm: "x10l6tqk", k87sOh: "xyx6v2t", kt4wiu: "x1mcfs9z", kY2c9j: "x1vjfegm", $$css: true as const }, navButton: { kVAEAm: "x10l6tqk", k87sOh: "xwa60dl", kIY38u: "x3sa99s", kY2c9j: "x1vjfegm", $$css: true as const }, navPrev: { kLqNvP: "x1ybfrjj", $$css: true as const }, navNext: { kt4wiu: "x1mcfs9z", $$css: true as const }, controlButton: { kMwMTN: "x9e3rv5", $$css: true as const } }; const dynamicStyles = { imageTransform: (transform: string) => [{ k3aq6I: transform != null ? "xsqj5wx" : transform, $$css: true as const }, { "--x-transform": transform != null ? transform : undefined }] }; /** * Pan distance (px) per arrow-key press while zoomed. Offsets move the * viewport in the arrow's direction — pressing ArrowRight reveals content to * the right, so the image itself shifts left (negative x), matching how * scrolling and pointer-drag panning feel. */ const KEYBOARD_PAN_STEP = 50; const KEYBOARD_PAN_OFFSETS: Record = { ArrowLeft: [KEYBOARD_PAN_STEP, 0], ArrowRight: [-KEYBOARD_PAN_STEP, 0], ArrowUp: [0, KEYBOARD_PAN_STEP], ArrowDown: [0, -KEYBOARD_PAN_STEP] }; /** * A fullscreen overlay for viewing images at full resolution. * * Supports single image and gallery modes. In gallery mode, provides * prev/next navigation via buttons and arrow keys. Optionally supports * zoom (double-click, Enter/Space on the image, or `+`/`-` to toggle 2x) * and pan (drag or arrow keys when zoomed; arrows navigate the gallery * when not zoomed). * * Uses the native `` element with `showModal()` for focus * trapping and top-layer placement. Dismiss via Escape, close button, * or backdrop click. * * @example * ``` * * * * ``` */ export function Lightbox({ isOpen, onOpenChange, media, index: controlledIndex, defaultIndex = 0, onIndexChange, hasZoom = false, hasAutoPlay = false, xstyle, className, style, ref, onClick: onClickProp, onKeyDown: onKeyDownProp, ...props }: LightboxProps) { const t = useTranslator(); const dialogRef = useRef(null); const mergedDialogRef = useMergedRefs(ref, dialogRef); const containerRef = useRef(null); const imageWrapperRef = useRef(null); const triggerElementRef = useRef(null); // Index state (controlled + uncontrolled) const isControlled = controlledIndex !== undefined; const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex); const index = isControlled ? controlledIndex : uncontrolledIndex; const setIndex = useCallback((value: number) => { if (!isControlled) { setUncontrolledIndex(value); } onIndexChange?.(value); }, [isControlled, onIndexChange, setUncontrolledIndex]); // Zoom/pan state const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); // Resolve current media item const mediaArray = useMemo(() => Array.isArray(media) ? media : [media], [media]); const isGallery = mediaArray.length > 1; const currentItem = mediaArray.length > 0 ? mediaArray[Math.min(index, mediaArray.length - 1)] : null; const currentType = currentItem?.type ?? 'image'; const isVideo = currentType === 'video'; const canPrev = isGallery && index > 0; const canNext = isGallery && index < mediaArray.length - 1; // Scroll lock useScrollLock(isOpen); // Reset zoom on image change useEffect(() => { // Reset image view state when the active media item changes. // eslint-disable-next-line @eslint-react/set-state-in-effect setZoom(1); // eslint-disable-next-line @eslint-react/set-state-in-effect setPan({ x: 0, y: 0 }); }, [index, currentItem?.src]); // Announce gallery navigation to screen readers. Moving between images only // updates the visual counter, which is silent to assistive tech, so mirror // each change in a polite live region (", 3 of 12", or "Image 3 of 12" // when the image has no alt). Announce only when the image changes during an // already-open session — not on mount, not when opening (even at a new // index, since the dialog's aria-label already names the current image), and // not on close. const announce = useAnnounce(); const prevIndexRef = useRef(index); const wasOpenRef = useRef(isOpen); useEffect(() => { const indexChanged = prevIndexRef.current !== index; const wasOpen = wasOpenRef.current; prevIndexRef.current = index; wasOpenRef.current = isOpen; if (!indexChanged || !isOpen || !wasOpen) { return; } const item = mediaArray[Math.min(index, mediaArray.length - 1)]; const position = { index: index + 1, total: mediaArray.length }; announce(item?.alt ? t('@astryx.lightbox.mediaPosition', { alt: item.alt, ...position }) : t('@astryx.lightbox.imagePosition', position)); }, [index, isOpen, announce, mediaArray, t]); // Open/close dialog useIsomorphicLayoutEffect(() => { const dialog = dialogRef.current; if (!dialog) { return; } if (isOpen && !dialog.open) { triggerElementRef.current = document.activeElement; dialog.showModal(); } else if (!isOpen && dialog.open) { dialog.close(); if (triggerElementRef.current instanceof HTMLElement) { triggerElementRef.current.focus(); } } }, [isOpen]); const handleClose = useCallback(() => { onOpenChange(false); }, [onOpenChange]); const { shouldDismissOnCloseRequest } = useLayerDismissal({ isActive: isOpen, onDismiss: handleClose }); // The native `cancel` event is the browser's own close-watcher firing: an // Android back gesture, or a close request the stack never saw a press for. // Escape presses the stack owns never arrive here — it preventDefault()s // those, which suppresses the close watcher. // // Always preventDefault so the browser cannot close a controlled // behind React's back, then answer with the stack's own rules: top-most // only, and never while an IME composition is in progress. const handleCancel = useCallback((e: React.SyntheticEvent) => { e.preventDefault(); if (!shouldDismissOnCloseRequest()) { return; } handleClose(); }, [handleClose, shouldDismissOnCloseRequest]); // Backdrop click. The layout container fills the whole transparent dialog, // so clicks on the visual backdrop (the dark area around the media) land on // the container, never on the dialog element itself — treat both as the // backdrop. A pan drag that ends over the backdrop still fires a click on // the common ancestor; ignore it so releasing a drag doesn't dismiss. const didDragRef = useRef(false); const handleBackdropClick = useCallback((e: ReactMouseEvent) => { if (didDragRef.current) { didDragRef.current = false; return; } if (e.target === e.currentTarget || e.target === containerRef.current) { handleClose(); } }, [handleClose]); // Gallery navigation const goToPrev = useCallback(() => { if (canPrev) { setIndex(index - 1); } }, [canPrev, index, setIndex]); const goToNext = useCallback(() => { if (canNext) { setIndex(index + 1); } }, [canNext, index, setIndex]); // Zoom: double-click, Enter/Space on the image, or +/- keys toggle 1x ↔ 2x. // Zoom changes are silent to assistive tech (only the transform changes), so // mirror them in the polite live region, including a hint that arrow keys // pan while zoomed. const applyZoom = useCallback((next: number) => { if (!hasZoom || isVideo || next === zoom) { return; } setZoom(next); setPan({ x: 0, y: 0 }); announce(next > 1 ? t('@astryx.lightbox.zoomedIn') : t('@astryx.lightbox.zoomedOut')); }, [hasZoom, isVideo, zoom, announce, t]); const handleDoubleClick = useCallback(() => { applyZoom(zoom === 1 ? 2 : 1); }, [applyZoom, zoom]); // Keyboard navigation. While zoomed, arrows pan the image (matching common // lightbox conventions); when not zoomed they navigate the gallery. const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (hasZoom && !isVideo) { if (e.key === '+' || e.key === '=') { e.preventDefault(); applyZoom(2); return; } if (e.key === '-') { e.preventDefault(); applyZoom(1); return; } if (zoom > 1 && KEYBOARD_PAN_OFFSETS[e.key] !== undefined) { e.preventDefault(); const [dx, dy] = KEYBOARD_PAN_OFFSETS[e.key]; setPan(prev => ({ x: prev.x + dx, y: prev.y + dy })); return; } } if (e.key === 'ArrowLeft') { e.preventDefault(); goToPrev(); } else if (e.key === 'ArrowRight') { e.preventDefault(); goToNext(); } }, [hasZoom, isVideo, zoom, applyZoom, goToPrev, goToNext]); // Enter/Space on the focused image wrapper (role="button") toggles zoom. const handleImageKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleDoubleClick(); } }, [handleDoubleClick]); // Pan: mouse drag when zoomed const handlePointerDown = useCallback((e: ReactMouseEvent) => { if (zoom <= 1 || !hasZoom) { return; } setIsDragging(true); didDragRef.current = false; dragStartRef.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; }, [zoom, hasZoom, pan]); useEffect(() => { if (!isDragging) { return; } const handlePointerMove = (e: PointerEvent) => { didDragRef.current = true; const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; setPan({ x: dragStartRef.current.panX + dx, y: dragStartRef.current.panY + dy }); }; const handlePointerUp = () => { setIsDragging(false); }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; }, [isDragging]); const isZoomed = zoom > 1; const isZoomTarget = hasZoom && !isVideo; const imageTransform = zoom === 1 ? null : `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`; if (!currentItem) { return null; } return { handleBackdropClick(e); onClickProp?.(e); }} onKeyDown={e => { handleKeyDown(e); onKeyDownProp?.(e); }} aria-label={currentItem.alt || t('@astryx.lightbox.mediaViewer')} {...mergeProps(themeProps('lightbox'), stylex.props(styles.dialog, overlayPaddingReset.reset, xstyle), className, style)} {...props}>
{/* Close button */} } label={t('@astryx.lightbox.close')} variant="ghost" onClick={handleClose} xstyle={[styles.closeButton, styles.controlButton]} /> {/* Gallery nav: prev — stays mounted and is disabled at the start of the range so pressing/arrowing to the boundary doesn't unmount the focused control and drop focus to . */} {isGallery && } label={t('@astryx.lightbox.previous')} variant="ghost" isDisabled={!canPrev} onClick={goToPrev} xstyle={[styles.navButton, styles.navPrev, styles.controlButton]} />} {/* Media + caption group (centered together) */}
{isVideo ?
{currentItem.caption &&
{currentItem.caption}
}
{/* Gallery nav: next — see "prev" above; stays mounted and disabled at the end of the range instead of unmounting. */} {isGallery && } label={t('@astryx.lightbox.next')} variant="ghost" isDisabled={!canNext} onClick={goToNext} xstyle={[styles.navButton, styles.navNext, styles.controlButton]} />} {/* Gallery counter */} {isGallery && mediaArray.length > 1 &&
{index + 1} / {mediaArray.length}
}
; } Lightbox.displayName = 'Lightbox';