Merge master and preserve pinned-row visibility. Remove redundant Chat and tool-tree menu forwarding, unify trigger names, and bind file targets at the consuming Slot. Address CI static and coverage gaps without changing upstream scroll assertions.
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
import type { HTMLAttributes } from 'react'
|
|
import { useEffect, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { IconCloseOutlineRegular } from './icons/index.tsx'
|
|
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.contextMenuProps - optional original-image gesture bindings.
|
|
* @param props.onClose - dismiss callback owned by the opener.
|
|
* @returns the modal preview dialog.
|
|
*/
|
|
export function ImageLightbox({ src, alt, labels, onClose, contextMenuProps }: {
|
|
src: string
|
|
alt: string
|
|
labels: ImageLightboxLabels
|
|
onClose: () => void
|
|
contextMenuProps?: Pick<HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'> | undefined
|
|
}) {
|
|
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 => {
|
|
// A portaled image menu owns Escape and Tab until it returns focus.
|
|
if (event.defaultPrevented || document.activeElement?.closest('[role="menu"]')) return
|
|
if (event.key === 'Escape') { event.stopPropagation(); onClose() }
|
|
if (event.key === 'Tab') { event.preventDefault(); closeRef.current?.focus() }
|
|
}
|
|
window.addEventListener('keydown', onKeyDown, true)
|
|
return () => {
|
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
restoreRef.current?.focus()
|
|
}
|
|
}, [onClose])
|
|
|
|
return createPortal(
|
|
<div
|
|
className={css.backdrop}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={labels.dialog}
|
|
>
|
|
<div className={css.stage}>
|
|
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
|
<img className={css.image} src={src} alt={alt} {...contextMenuProps} />
|
|
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
|
<IconCloseOutlineRegular size={16} />
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|