Body-portal the lightbox and toast so transformed ancestors cannot trap their fixed positioning (a lightbox opened from a chat message covered only the chat column); make the toast pointer-transparent; observe the rail element's own size instead of window resizes; consume vertical wheel ticks exclusively via a non-passive listener with LINE/PAGE delta normalization; keep the start position when the rail mounts over an existing draft; honor prefers-reduced-motion for the toast, remove-control, and paging; retry loads through the guarded load effect; note the deliberate promptError re-announce; pin the intake toast in the assembled snapshot; sync the superseded multimodal note and package docs.
62 lines
2.1 KiB
TypeScript
62 lines
2.1 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import css from './ImageLightbox.module.css'
|
||
|
||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||
export interface ImageLightboxLabels {
|
||
/** Accessible name of the preview dialog. */
|
||
dialog: string
|
||
/** Accessible label of the close control. */
|
||
close: string
|
||
}
|
||
|
||
/**
|
||
* Document-level original-image preview opened by clicking a thumbnail.
|
||
* Closes on Escape, backdrop press, or the close control, and restores focus
|
||
* to the opener on unmount. Rendered through a body portal: an opener inside
|
||
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
||
* in that ancestor's box instead of covering the viewport.
|
||
*
|
||
* @param props.src - the original image URL.
|
||
* @param props.alt - the image's alt text.
|
||
* @param props.labels - dialog and close-control strings.
|
||
* @param props.onClose - dismiss callback owned by the opener.
|
||
* @returns the modal preview dialog.
|
||
*/
|
||
export function ImageLightbox({ src, alt, labels, onClose }: {
|
||
src: string
|
||
alt: string
|
||
labels: ImageLightboxLabels
|
||
onClose: () => void
|
||
}) {
|
||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||
const restoreRef = useRef<HTMLElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
closeRef.current?.focus()
|
||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||
if (event.key === 'Escape') onClose()
|
||
}
|
||
window.addEventListener('keydown', onKeyDown)
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown)
|
||
restoreRef.current?.focus()
|
||
}
|
||
}, [onClose])
|
||
|
||
return createPortal(
|
||
<div
|
||
className={css.backdrop}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={labels.dialog}
|
||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||
>
|
||
<img className={css.image} src={src} alt={alt} />
|
||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|