{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elements-image-gallery",
  "type": "registry:component",
  "title": "Image gallery",
  "description": "A compact grid of returned images that opens each one in a shared lightbox.",
  "registryDependencies": [
    "https://r.assistant-ui.com/elements-surfaces.json",
    "https://r.assistant-ui.com/elements-href.json",
    "https://r.assistant-ui.com/elements-range.json",
    "dialog"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/elements/image-gallery.tsx",
      "sourcePath": "packages/ui/src/components/react/assistant-ui/elements/image-gallery.tsx",
      "content": "\"use client\";\n\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ExternalLinkIcon,\n  ImageOffIcon,\n} from \"lucide-react\";\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n} from \"react\";\nimport { Dialog, DialogContent, DialogTitle } from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\nimport { safeHref } from \"../utils/href\";\nimport { clamp } from \"../utils/range\";\nimport { field, ghostButton, mono } from \"./surfaces\";\n\nexport interface GalleryImage {\n  id: string;\n  src: string;\n  alt: string;\n  caption?: string | undefined;\n  source?: { label?: string | undefined; url?: string | undefined } | undefined;\n}\n\nexport interface ImageGalleryProps extends Omit<\n  ComponentProps<\"div\">,\n  \"children\"\n> {\n  images: readonly GalleryImage[];\n  maxVisible?: number | undefined;\n  onOpen?: ((id: string) => void) | undefined;\n}\n\nconst keyForImage = (image: GalleryImage) => `${image.id}\\u0000${image.src}`;\n\nexport function ImageGallery({\n  images,\n  maxVisible = 6,\n  onOpen,\n  className,\n  ...props\n}: ImageGalleryProps) {\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [failedImages, setFailedImages] = useState<ReadonlySet<string>>(\n    () => new Set(),\n  );\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const index = images.findIndex((image) => image.id === activeId);\n  if (activeId !== null && index === -1) setActiveId(null);\n  const activeImage = index === -1 ? undefined : images[index];\n  const sourceHref = safeHref(activeImage?.source?.url);\n  const visibleCount = Math.floor(clamp(maxVisible, 1, images.length));\n  const visibleImages = images.slice(0, visibleCount);\n\n  const close = useCallback(() => {\n    setActiveId(null);\n    triggerRef.current?.focus();\n  }, []);\n\n  useEffect(() => {\n    if (activeImage === undefined) return;\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"ArrowLeft\" && index > 0) {\n        event.preventDefault();\n        setActiveId(images[index - 1]?.id ?? null);\n      } else if (event.key === \"ArrowRight\" && index < images.length - 1) {\n        event.preventDefault();\n        setActiveId(images[index + 1]?.id ?? null);\n      }\n    };\n    // The dialog stops keydown propagation, so only a capture listener sees the arrows.\n    document.addEventListener(\"keydown\", onKeyDown, true);\n\n    return () => document.removeEventListener(\"keydown\", onKeyDown, true);\n  }, [activeImage, images, index]);\n\n  const open = (nextIndex: number, trigger: HTMLButtonElement) => {\n    const image = images[nextIndex];\n    if (!image) return;\n    triggerRef.current = trigger;\n    setActiveId(image.id);\n    onOpen?.(image.id);\n  };\n\n  const markFailed = (image: GalleryImage) => {\n    const key = keyForImage(image);\n    setFailedImages((current) =>\n      current.has(key) ? current : new Set(current).add(key),\n    );\n  };\n\n  if (images.length === 0) return null;\n\n  return (\n    <div\n      data-slot=\"image-gallery\"\n      className={cn(\"@container w-full max-w-md\", className)}\n      {...props}\n    >\n      <div className=\"grid grid-cols-2 gap-2 @sm:grid-cols-3\">\n        {visibleImages.map((image, imageIndex) => {\n          const failed = failedImages.has(keyForImage(image));\n          const overflow =\n            imageIndex === visibleCount - 1 && images.length > visibleCount;\n\n          return (\n            <button\n              key={image.id}\n              type=\"button\"\n              aria-label={`Open image: ${image.alt}`}\n              onClick={(event) => open(imageIndex, event.currentTarget)}\n              className={cn(\n                field,\n                \"focus-visible:ring-foreground/30 relative aspect-square overflow-hidden rounded-xl transition-[transform,background-color] duration-150 outline-none hover:scale-[1.01] focus-visible:ring-1 active:scale-[0.98] motion-reduce:transition-none\",\n              )}\n            >\n              {failed ? (\n                <span className=\"text-foreground/35 flex size-full items-center justify-center\">\n                  <ImageOffIcon aria-hidden className=\"size-6\" />\n                </span>\n              ) : (\n                <img\n                  src={image.src}\n                  alt={image.alt}\n                  loading=\"lazy\"\n                  className=\"size-full object-cover\"\n                  onError={() => markFailed(image)}\n                />\n              )}\n              {overflow ? (\n                <span className=\"bg-foreground/65 text-background absolute inset-0 flex items-center justify-center text-lg font-medium\">\n                  +{images.length - visibleCount}\n                </span>\n              ) : null}\n            </button>\n          );\n        })}\n      </div>\n      {activeImage ? (\n        <Dialog\n          open\n          onOpenChange={(nextOpen: boolean) => {\n            if (!nextOpen) close();\n          }}\n        >\n          <DialogContent className=\"gap-3 p-3 duration-150 motion-reduce:animate-none sm:max-w-4xl\">\n            <DialogTitle className=\"sr-only\">\n              Image {index + 1} of {images.length}\n            </DialogTitle>\n            <div className=\"flex min-h-0 items-center justify-center px-10\">\n              {failedImages.has(keyForImage(activeImage)) ? (\n                <div className=\"text-foreground/35 flex h-[min(75vh,32rem)] w-full items-center justify-center\">\n                  <ImageOffIcon aria-hidden className=\"size-8\" />\n                </div>\n              ) : (\n                <img\n                  src={activeImage.src}\n                  alt={activeImage.alt}\n                  className=\"max-h-[75vh] max-w-full object-contain\"\n                  onError={() => markFailed(activeImage)}\n                />\n              )}\n            </div>\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <button\n                type=\"button\"\n                aria-label=\"Previous image\"\n                disabled={index === 0}\n                onClick={() => setActiveId(images[index - 1]?.id ?? null)}\n                className={cn(\n                  ghostButton,\n                  \"size-8 shrink-0 disabled:pointer-events-none disabled:opacity-30\",\n                )}\n              >\n                <ChevronLeftIcon aria-hidden className=\"size-4\" />\n              </button>\n              <div className=\"flex min-w-0 flex-1 items-center gap-2 text-sm\">\n                {activeImage.caption ? (\n                  <p className=\"text-foreground/75 truncate\">\n                    {activeImage.caption}\n                  </p>\n                ) : null}\n                {activeImage.source?.label ? (\n                  sourceHref ? (\n                    <a\n                      href={sourceHref}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-foreground/50 hover:text-foreground/80 flex min-w-0 items-center gap-1 truncate transition-colors\"\n                    >\n                      <span className=\"truncate\">\n                        {activeImage.source.label}\n                      </span>\n                      <ExternalLinkIcon\n                        aria-hidden\n                        className=\"size-3 shrink-0\"\n                      />\n                      <span className=\"sr-only\">(opens in a new tab)</span>\n                    </a>\n                  ) : (\n                    <span className=\"text-foreground/50 truncate\">\n                      {activeImage.source.label}\n                    </span>\n                  )\n                ) : null}\n              </div>\n              <span className={cn(mono, \"text-foreground/45 shrink-0\")}>\n                {index + 1} / {images.length}\n              </span>\n              <button\n                type=\"button\"\n                aria-label=\"Next image\"\n                disabled={index === images.length - 1}\n                onClick={() => setActiveId(images[index + 1]?.id ?? null)}\n                className={cn(\n                  ghostButton,\n                  \"size-8 shrink-0 disabled:pointer-events-none disabled:opacity-30\",\n                )}\n              >\n                <ChevronRightIcon aria-hidden className=\"size-4\" />\n              </button>\n            </div>\n          </DialogContent>\n        </Dialog>\n      ) : null}\n    </div>\n  );\n}\n"
    }
  ]
}