Second alignment step for #2248: document-level drag intake behind the new DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged consecutive assistant images), image limits raised to 20/10MiB/100MiB and published to clients as the imageLimits projection, whole-batch intake pre-check with product-copy banners, and attachment-error reasons mapped to localized copy.
65 lines
2.2 KiB
TypeScript
65 lines
2.2 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
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}
|
|
>
|
|
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
|
<img className={css.image} src={src} alt={alt} />
|
|
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
|
<IconCloseOutline16 size={16} />
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|