{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-gallery",
  "title": "Product Gallery",
  "description": "Media selection with thumbnails, zoom, fullscreen viewing, and required alt text.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "dialog",
    "toggle",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/product-gallery.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { ChevronLeft, ChevronRight, Maximize2, X, ZoomIn, ZoomOut } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent,\n  type RefObject,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Toggle } from \"@/components/ui/toggle\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const PRODUCT_GALLERY_VARIANTS = [\"stacked\", \"side\", \"compact\"] as const;\nexport type ProductGalleryVariant = (typeof PRODUCT_GALLERY_VARIANTS)[number];\nexport type ProductGalleryMedia = { value: string; src: string; alt: string };\nexport type ProductGalleryProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  variant?: ProductGalleryVariant;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n};\ntype GalleryContext = {\n  id: string;\n  variant: ProductGalleryVariant;\n  items: ProductGalleryMedia[];\n  value: string;\n  select: (value: string) => void;\n  step: (offset: number) => void;\n  register: (media: ProductGalleryMedia) => () => void;\n  zoomed: boolean;\n  setZoomed: (zoomed: boolean) => void;\n};\nconst Context = createContext<GalleryContext | null>(null);\nfunction useGallery(part: string) {\n  const context = useContext(Context);\n  if (!context) throw new Error(`${part} must be used within ProductGallery`);\n  return context;\n}\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype ScrollEdges = { start: boolean; end: boolean; vertical: boolean };\n\n/** Tracks which ends of a scroller hide content, so the edges can fade. */\nfunction useScrollEdges(ref: RefObject<HTMLElement | null>) {\n  const [edges, setEdges] = useState<ScrollEdges>({ start: false, end: false, vertical: false });\n  useIsomorphicLayoutEffect(() => {\n    const element = ref.current;\n    if (!element) return;\n    const measure = () => {\n      const vertical = element.scrollHeight - element.clientHeight > 1;\n      const offset = vertical ? element.scrollTop : Math.abs(element.scrollLeft);\n      const room = vertical\n        ? element.scrollHeight - element.clientHeight\n        : element.scrollWidth - element.clientWidth;\n      const next = { start: offset > 1, end: room - offset > 1, vertical };\n      setEdges((previous) =>\n        previous.start === next.start &&\n        previous.end === next.end &&\n        previous.vertical === next.vertical\n          ? previous\n          : next,\n      );\n    };\n    measure();\n    element.addEventListener(\"scroll\", measure, { passive: true });\n    const observer = typeof ResizeObserver === \"function\" ? new ResizeObserver(measure) : null;\n    observer?.observe(element);\n    return () => {\n      element.removeEventListener(\"scroll\", measure);\n      observer?.disconnect();\n    };\n  }, []);\n  return edges;\n}\nconst controlClass =\n  \"grid size-7 min-w-0 cursor-pointer place-items-center rounded-full border-0 bg-card/84 p-0 text-muted-foreground shadow-[0_0_0_1px_var(--border),0_1px_2px_oklch(0_0_0/0.08)] backdrop-blur-sm transition-[background-color,color,scale] duration-[120ms,120ms,140ms] ease-[ease-out,ease-out,cubic-bezier(0.23,1,0.32,1)] hover:bg-accent hover:text-accent-foreground focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-[0.94] aria-pressed:bg-accent aria-pressed:text-accent-foreground data-[state=on]:bg-accent data-pressed:bg-accent data-[state=on]:text-accent-foreground data-pressed:text-accent-foreground motion-reduce:transition-none motion-reduce:active:scale-100 dark:hover:bg-accent [&_svg:not([class*='size-'])]:size-3.5\";\n\nconst productGalleryVariants = cva(\n  \"grid min-w-0 grid-cols-[minmax(0,1fr)] text-[13px]/[18px] text-foreground [grid-template-areas:'view'_'thumbs']\",\n  {\n    variants: {\n      variant: {\n        stacked: \"gap-3\",\n        side: \"gap-3 grid-cols-[72px_minmax(0,1fr)] [grid-template-areas:'thumbs_view'] max-[560px]:grid-cols-[minmax(0,1fr)] max-[560px]:[grid-template-areas:'view'_'thumbs']\",\n        compact: \"gap-2\",\n      },\n    },\n  },\n);\n\nexport function ProductGallery({\n  variant = \"stacked\",\n  value,\n  defaultValue,\n  onValueChange,\n  className,\n  children,\n  ...props\n}: ProductGalleryProps) {\n  const id = useId();\n  const [items, setItems] = useState<ProductGalleryMedia[]>([]);\n  const [internal, setInternal] = useState(defaultValue);\n  const [zoomed, setZoomed] = useState(false);\n  const current = value ?? internal ?? items[0]?.value ?? \"\";\n  const select = (next: string) => {\n    setZoomed(false);\n    if (next === current) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n  const step = (offset: number) => {\n    if (items.length === 0) return;\n    const index = Math.max(\n      items.findIndex((item) => item.value === current),\n      0,\n    );\n    const next = items[(index + offset + items.length) % items.length];\n    if (next) select(next.value);\n  };\n  const register = (media: ProductGalleryMedia) => {\n    setItems((previous) => {\n      const index = previous.findIndex((item) => item.value === media.value);\n      if (index === -1) return [...previous, media];\n      return previous.map((item, position) => (position === index ? media : item));\n    });\n    return () => setItems((previous) => previous.filter((item) => item.value !== media.value));\n  };\n  return (\n    <Context.Provider\n      value={{ id, variant, items, value: current, select, step, register, zoomed, setZoomed }}\n    >\n      <div\n        data-slot=\"product-gallery\"\n        className={cn(productGalleryVariants({ variant }), className)}\n        {...props}\n        data-variant={variant}\n      >\n        {children}\n      </div>\n    </Context.Provider>\n  );\n}\n\nexport function ProductGalleryViewport({ children, className, ...props }: ComponentProps<\"div\">) {\n  const context = useGallery(\"ProductGalleryViewport\");\n  const [origin, setOrigin] = useState(\"50% 50%\");\n  const index = context.items.findIndex((item) => item.value === context.value);\n  const media = context.items[index];\n  return (\n    <div\n      data-slot=\"product-gallery-viewport\"\n      className={cn(\n        \"relative aspect-4/3 min-w-0 overflow-hidden bg-muted outline -outline-offset-1 outline-foreground/8 [grid-area:view]\",\n        context.variant === \"compact\" ? \"rounded-xl\" : \"rounded-[14px]\",\n        className,\n      )}\n      {...props}\n    >\n      {media ? (\n        // biome-ignore lint/a11y/useKeyWithClickEvents: ProductGalleryZoom is the keyboard control.\n        // biome-ignore lint/performance/noImgElement: registry source is framework-agnostic.\n        <img\n          key={media.value}\n          src={media.src}\n          alt={media.alt}\n          className={cn(\n            \"block size-full animate-in object-contain duration-200 ease-out fade-in-0 motion-reduce:animate-none transition-[scale] duration-240 ease-out-quint motion-reduce:transition-none\",\n            context.zoomed ? \"scale-200 cursor-zoom-out\" : \"scale-none cursor-zoom-in\",\n          )}\n          draggable={false}\n          onClick={() => context.setZoomed(!context.zoomed)}\n          onPointerMove={(event) => {\n            if (!context.zoomed) return;\n            const box = event.currentTarget.getBoundingClientRect();\n            if (!box.width || !box.height) return;\n            const x = ((event.clientX - box.left) / box.width) * 100;\n            const y = ((event.clientY - box.top) / box.height) * 100;\n            setOrigin(`${x.toFixed(1)}% ${y.toFixed(1)}%`);\n          }}\n          style={{ transformOrigin: origin }}\n        />\n      ) : null}\n      <p id={`${context.id}-position`} aria-live=\"polite\" className=\"sr-only\">\n        {media ? `Image ${index + 1} of ${context.items.length}: ${media.alt}` : \"\"}\n      </p>\n      {children ? <div className=\"absolute top-2.5 right-2.5 flex gap-1.5\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport function ProductGalleryZoom({\n  onClick,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"value\">) {\n  const context = useGallery(\"ProductGalleryZoom\");\n  return (\n    <Toggle\n      data-slot=\"product-gallery-zoom\"\n      aria-label=\"Zoom image\"\n      className={cn(controlClass, className)}\n      {...props}\n      type=\"button\"\n      pressed={context.zoomed}\n      onPressedChange={context.setZoomed}\n      onClick={onClick}\n    >\n      {context.zoomed ? (\n        <ZoomOut size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n      ) : (\n        <ZoomIn size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n      )}\n    </Toggle>\n  );\n}\n\n/**\n * Moves focus to the close button as the fullscreen view mounts. It runs before the primitive's\n * own initial focus, which Radix and Base UI both skip when focus is already inside, so neither\n * needs primitive-specific props.\n */\nfunction InitialFocus({ fallback }: { fallback: React.RefObject<HTMLElement | null> }) {\n  useLayoutEffect(() => {\n    fallback.current?.focus();\n  }, [fallback]);\n  return null;\n}\n\nexport function ProductGalleryFullscreen({\n  onClick,\n  className,\n  \"aria-label\": label = \"View fullscreen\",\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useGallery(\"ProductGalleryFullscreen\");\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const [open, setOpen] = useState(false);\n  const index = context.items.findIndex((item) => item.value === context.value);\n  const media = context.items[index];\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"ArrowRight\") context.step(1);\n    else if (event.key === \"ArrowLeft\") context.step(-1);\n    else return;\n    event.preventDefault();\n  };\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild onClick={onClick}>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          data-slot=\"product-gallery-fullscreen\"\n          aria-label={label}\n          className={cn(controlClass, className)}\n          {...props}\n          type=\"button\"\n        >\n          <Maximize2 size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n        </Button>\n      </DialogTrigger>\n      <DialogContent\n        showCloseButton={false}\n        aria-describedby={undefined}\n        className=\"box-border block max-h-[calc(100%_-_32px)] w-[min(100%_-_32px,1040px)] max-w-full gap-0 rounded-[14px] border-0 bg-popover p-1 text-[13px]/[18px] text-popover-foreground shadow-[0_0_0_1px_var(--border-strong),0_16px_40px_-12px_oklch(0_0_0/0.32)] duration-180 ease-[cubic-bezier(0.16,1,0.3,1)] data-[state=closed]:zoom-out-96 data-closed:zoom-out-96 data-[state=open]:zoom-in-96 data-open:zoom-in-96 sm:max-w-full motion-reduce:data-[state=closed]:animate-none motion-reduce:data-closed:animate-none motion-reduce:data-[state=open]:animate-none motion-reduce:data-open:animate-none\"\n        onKeyDown={handleKeyDown}\n      >\n        <DialogTitle className=\"sr-only\">Fullscreen product images</DialogTitle>\n        {media ? (\n          <div className=\"grid gap-1\">\n            {/* biome-ignore lint/performance/noImgElement: registry source is framework-agnostic. */}\n            <img\n              key={media.value}\n              src={media.src}\n              alt={media.alt}\n              className=\"block max-h-[calc(100dvh_-_120px)] w-full animate-in rounded-[10px] bg-muted object-contain duration-200 ease-out fade-in-0 motion-reduce:animate-none\"\n            />\n            <div className=\"flex items-center gap-1 py-1 pr-1 pl-2.5\">\n              <p\n                aria-live=\"polite\"\n                className=\"m-0 min-w-0 flex-1 text-[12px] text-subtle-foreground tabular-nums\"\n              >\n                {index + 1} / {context.items.length}\n                <span className=\"sr-only\">: {media.alt}</span>\n              </p>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon\"\n                aria-label=\"Previous image\"\n                className={controlClass}\n                onClick={() => context.step(-1)}\n              >\n                <ChevronLeft size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon\"\n                aria-label=\"Next image\"\n                className={controlClass}\n                onClick={() => context.step(1)}\n              >\n                <ChevronRight\n                  size={14}\n                  className=\"size-3.5\"\n                  strokeWidth={1.75}\n                  aria-hidden=\"true\"\n                />\n              </Button>\n              <DialogClose asChild>\n                <Button\n                  ref={closeRef}\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  aria-label=\"Close fullscreen\"\n                  className={controlClass}\n                >\n                  <X size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n                </Button>\n              </DialogClose>\n            </div>\n          </div>\n        ) : null}\n        <InitialFocus fallback={closeRef} />\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nconst MOVES: Record<string, (index: number, count: number) => number> = {\n  ArrowRight: (index, count) => (index + 1) % count,\n  ArrowDown: (index, count) => (index + 1) % count,\n  ArrowLeft: (index, count) => (index - 1 + count) % count,\n  ArrowUp: (index, count) => (index - 1 + count) % count,\n  Home: () => 0,\n  End: (_index, count) => count - 1,\n};\n\nexport function ProductGalleryThumbnails({\n  className,\n  onKeyDown,\n  \"aria-label\": label = \"Product images\",\n  ...props\n}: ComponentProps<\"div\">) {\n  const context = useGallery(\"ProductGalleryThumbnails\");\n  const ref = useRef<HTMLDivElement>(null);\n  const edges = useScrollEdges(ref);\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    const move = MOVES[event.key];\n    if (event.defaultPrevented || !move || !ref.current) return;\n    const thumbs = Array.from(\n      ref.current.querySelectorAll<HTMLButtonElement>(\"[data-gallery-item]:not(:disabled)\"),\n    );\n    if (thumbs.length === 0) return;\n    event.preventDefault();\n    const index = thumbs.indexOf(document.activeElement as HTMLButtonElement);\n    const next = thumbs[move(Math.max(index, 0), thumbs.length)];\n    next?.focus();\n    if (next?.dataset.value) context.select(next.dataset.value);\n  };\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a labelled group of thumbnail buttons, not a fieldset.\n    <div\n      role=\"group\"\n      aria-label={label}\n      aria-describedby={`${context.id}-position`}\n      data-slot=\"product-gallery-thumbnails\"\n      data-overflow-start={edges.start ? \"\" : undefined}\n      data-overflow-end={edges.end ? \"\" : undefined}\n      className={cn(\n        \"-m-1 flex min-w-0 overflow-auto p-1 [grid-area:thumbs]\",\n        context.variant === \"compact\" ? \"gap-1.5\" : \"gap-2\",\n        context.variant === \"side\" && \"flex-col max-[560px]:flex-row\",\n        edges.vertical\n          ? \"data-overflow-start:mask-t-from-[calc(100%-24px)] data-overflow-end:mask-b-from-[calc(100%-24px)]\"\n          : \"data-overflow-start:mask-l-from-[calc(100%-24px)] data-overflow-end:mask-r-from-[calc(100%-24px)]\",\n        className,\n      )}\n      {...props}\n      ref={ref}\n      onKeyDown={handleKeyDown}\n    />\n  );\n}\n\nexport function ProductGalleryItem({\n  value,\n  src,\n  alt,\n  onClick,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"value\" | \"children\"> & ProductGalleryMedia) {\n  const context = useGallery(\"ProductGalleryItem\");\n  const { register } = context;\n  useIsomorphicLayoutEffect(() => register({ value, src, alt }), [value, src, alt]);\n  const selected = context.value === value;\n  const orphaned = !context.items.some((item) => item.value === context.value);\n  const compact = context.variant === \"compact\";\n  return (\n    <Button\n      variant=\"ghost\"\n      data-slot=\"product-gallery-item\"\n      aria-label={alt}\n      className={cn(\n        \"block flex-none cursor-pointer overflow-hidden border-0 bg-muted p-0 opacity-62 shadow-[inset_0_0_0_1px_var(--border)] transition-[opacity,box-shadow,scale] duration-[120ms,160ms,140ms] ease-[ease-out,cubic-bezier(0.23,1,0.32,1),cubic-bezier(0.23,1,0.32,1)] hover:bg-muted hover:opacity-90 focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-ring dark:hover:bg-muted active:scale-[0.97] aria-[current=true]:opacity-100 aria-[current=true]:shadow-[0_0_0_2px_var(--card),0_0_0_3.5px_var(--foreground)] motion-reduce:transition-none motion-reduce:active:scale-100\",\n        compact ? \"size-12 rounded-lg\" : \"size-16 rounded-[10px]\",\n        className,\n      )}\n      {...props}\n      type=\"button\"\n      data-gallery-item=\"\"\n      data-value={value}\n      aria-current={selected || undefined}\n      tabIndex={selected || orphaned ? 0 : -1}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.select(value);\n      }}\n    >\n      {/* biome-ignore lint/performance/noImgElement: registry source is framework-agnostic. */}\n      <img src={src} alt=\"\" draggable={false} className=\"block size-full object-cover\" />\n    </Button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/product-gallery.tsx"
    }
  ],
  "categories": [
    "marketing",
    "media"
  ],
  "type": "registry:ui"
}