import { useVisibility } from "@charcuterie/logic" import { type ReactNode, useEffect, useState } from "react" import { Button } from "../Button/Button.tsx" import { Dialog } from "../Dialog/Dialog.tsx" import { toClassName } from "../toClassName.ts" export type LightboxImage = { readonly alt: string readonly src: string readonly caption?: ReactNode } export type LightboxProps = { /** Optional ordered gallery. The trigger opens at initialIndex. */ images?: readonly LightboxImage[] initialIndex?: number /** * Required, and the whole reason this is not a bare `` in a * dialog. It names both the enlarged image and — via `heading`'s * default below — the dialog itself, and a poster grid of unnamed * images is unnavigable rather than merely imperfect. Same rule * `MediaTile` makes. */ alt: string /** Shown under the enlarged image. A year, a source, a filename. */ caption?: ReactNode /** Applied to the built-in trigger button, not the dialog. */ className?: string /** * The dialog's accessible name and visible heading. Defaults to * `alt`, which is right when the thumbnail's alt is already the * title ("THE OUTFIT poster"); pass it when the two should differ. */ heading?: string /** * **Controlled** open state. Omit for the uncontrolled default, * where the trigger owns it through `useVisibility`. Present means * the caller owns it and the built-in trigger becomes a plain * button that only *reports* intent through `onOpenChange`. */ isOpen?: boolean onOpenChange?: (isOpen: boolean) => void src: string /** * The clickable thumbnail — a smaller ``, usually. Omitted in * the controlled, trigger-elsewhere case, where something else in * the page opens the lightbox and this renders only the overlay. */ thumbnail?: ReactNode } /** * A thumbnail that opens its own full-size view — the one image * interaction three apps hand-roll and none get the focus trap or * the scroll lock right. It is a **skin over `Dialog`**, not a second * dialog: `Dialog` (through `Modal`/`OverlayPanel`) owns the portal, * the scrim, Escape, the focus trap and `lockScrollBehind`, and * everything that makes those correct in every browser the fleet * runs. What this adds is the image-specific part — a `cursor-zoom-in` * trigger and an `object-contain` image clamped to the viewport so a * 2:3 poster and a 16:9 still both land whole. * * ### Controlled and not, one component * * `useVisibility` runs unconditionally — hooks cannot be conditional * — but is *consulted* only when `isOpen` is absent. A caller that * passes `isOpen` owns the truth; the internal store is then an * unread two-field object, which is cheaper than splitting this into * two components that share a Modal body. */ export const Lightbox = ({ alt, caption, className, heading, images, initialIndex = 0, isOpen, onOpenChange, src, thumbnail, }: LightboxProps): ReactNode => { const isControlled = isOpen !== undefined const { hide, isVisible, show } = useVisibility() const [index, setIndex] = useState(initialIndex) const isShown = isControlled ? isOpen : isVisible const gallery = images?.length ? images : [{ alt, caption, src }] const activeIndex = Math.max( 0, Math.min(index, gallery.length - 1), ) const active = gallery[activeIndex] ?? { alt, caption, src, } const hasMultiple = gallery.length > 1 useEffect(() => { if (!isShown || !hasMultiple) return const navigate = (event: KeyboardEvent) => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) return if ( event.target instanceof HTMLElement && event.target.closest( "input, textarea, [contenteditable=true]", ) ) return const delta = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0 if (delta === 0) return event.preventDefault() setIndex( (current) => (Math.max( 0, Math.min(current, gallery.length - 1), ) + delta + gallery.length) % gallery.length, ) } document.addEventListener("keydown", navigate) return () => document.removeEventListener("keydown", navigate) }, [isShown, hasMultiple, gallery.length]) const requestOpen = () => { setIndex(initialIndex) if (!isControlled) { show() } onOpenChange?.(true) } const requestClose = () => { if (!isControlled) { hide() } onOpenChange?.(false) } return ( <> {thumbnail === undefined ? null : ( )} {activeIndex + 1} of {gallery.length} ) : undefined } >
{active.alt} {active.caption ? (
{active.caption}
) : null}
) }