ImageLightbox.tsx 2.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import { useEffect, useRef } from 'react'
  2. import { createPortal } from 'react-dom'
  3. import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
  4. import css from './ImageLightbox.module.css'
  5. /** Lightbox strings the owner resolves from its own locale namespace. */
  6. export interface ImageLightboxLabels {
  7. /** Accessible name of the preview dialog. */
  8. dialog: string
  9. /** Accessible label of the close control. */
  10. close: string
  11. }
  12. /**
  13. * Document-level original-image preview opened by clicking a thumbnail.
  14. * Closes on Escape, backdrop press, or the close control, and restores focus
  15. * to the opener on unmount. Rendered through a body portal: an opener inside
  16. * a transformed or filtered ancestor would otherwise trap the fixed backdrop
  17. * in that ancestor's box instead of covering the viewport.
  18. *
  19. * @param props.src - the original image URL.
  20. * @param props.alt - the image's alt text.
  21. * @param props.labels - dialog and close-control strings.
  22. * @param props.onClose - dismiss callback owned by the opener.
  23. * @returns the modal preview dialog.
  24. */
  25. export function ImageLightbox({ src, alt, labels, onClose }: {
  26. src: string
  27. alt: string
  28. labels: ImageLightboxLabels
  29. onClose: () => void
  30. }) {
  31. const closeRef = useRef<HTMLButtonElement | null>(null)
  32. const restoreRef = useRef<HTMLElement | null>(null)
  33. useEffect(() => {
  34. restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
  35. closeRef.current?.focus()
  36. const onKeyDown = (event: globalThis.KeyboardEvent): void => {
  37. if (event.key === 'Escape') onClose()
  38. }
  39. window.addEventListener('keydown', onKeyDown)
  40. return () => {
  41. window.removeEventListener('keydown', onKeyDown)
  42. restoreRef.current?.focus()
  43. }
  44. }, [onClose])
  45. return createPortal(
  46. <div
  47. className={css.backdrop}
  48. role="dialog"
  49. aria-modal="true"
  50. aria-label={labels.dialog}
  51. >
  52. <div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
  53. <img className={css.image} src={src} alt={alt} />
  54. <button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
  55. <IconCloseOutline16 size={16} />
  56. </button>
  57. </div>,
  58. document.body,
  59. )
  60. }