{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightbox-gallery",
  "type": "registry:ui",
  "title": "Lightbox Gallery",
  "description": "A thumbnail grid that opens a full-screen <dialog> lightbox: FLIP from the thumbnail, swipe, pinch / double-click zoom and a thumbnail strip.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/lightbox-gallery.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface LightboxImage {\n  src: string;\n  /** Required alternative text. */\n  alt: string;\n  caption?: string;\n  /** Intrinsic size (used to fit the image before it loads). */\n  width?: number;\n  height?: number;\n  /** Smaller source for the grid and the thumbnail strip. Defaults to `src`. */\n  thumbSrc?: string;\n}\n\nexport interface LightboxGalleryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  images: LightboxImage[];\n  /** Grid columns on wide containers (two on narrow ones). */\n  columns?: number;\n  /** Grid gap in px. */\n  gap?: number;\n  /** Thumbnail crop, e.g. \"1/1\", \"4/3\", \"3/4\". */\n  aspectRatio?: string;\n  /** Thumbnail corner radius in px. */\n  rounded?: number;\n  /** Show the thumbnail strip inside the lightbox. */\n  thumbnails?: boolean;\n  /** Wrap from the last image to the first. */\n  loop?: boolean;\n  /** Largest zoom factor. */\n  maxZoom?: number;\n  /** Open / close animation length in ms. */\n  duration?: number;\n  /** Lightbox backdrop: always dark, or following the page theme. */\n  backdrop?: \"dark\" | \"theme\";\n  /** Accessible name of the gallery. */\n  label?: string;\n  onIndexChange?: (index: number, open: boolean) => void;\n  accent?: string;\n}\n\ninterface Zoom {\n  s: number;\n  x: number;\n  y: number;\n}\ninterface Rect {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\nconst EASE = \"cubic-bezier(0.2, 0.9, 0.1, 1)\";\nconst TOP = 60;\nconst CAPTION = 48;\nconst STRIP = 84;\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n/** Current translate of an element, read from its computed transform. */\nfunction translateOf(el: Element) {\n  const t = getComputedStyle(el).transform;\n  if (!t || t === \"none\") return { x: 0, y: 0 };\n  const m = new DOMMatrixReadOnly(t);\n  return { x: m.m41, y: m.m42 };\n}\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/** Fits an image into the viewport area that the chrome leaves free. */\nfunction fit(vp: { w: number; h: number }, dim: { w: number; h: number }, strip: boolean): Rect {\n  const side = vp.w < 640 ? 12 : 76;\n  const bottom = CAPTION + (strip ? STRIP : 12);\n  const aw = Math.max(40, vp.w - side * 2);\n  const ah = Math.max(40, vp.h - TOP - bottom);\n  const s = Math.min(aw / dim.w, ah / dim.h);\n  const width = dim.w * s;\n  const height = dim.h * s;\n  return { left: r2((vp.w - width) / 2), top: r2(TOP + (ah - height) / 2), width: r2(width), height: r2(height) };\n}\n\n/**\n * A thumbnail grid that opens a full-screen lightbox in a native <dialog>. The image grows out of\n * its thumbnail (FLIP plus a clip-path that undoes the crop) and shrinks back into the current one\n * on close. Inside: swipe or arrow keys between images with neighbours preloaded, pinch / double-click\n * / wheel zoom with panning, a caption, a \"3 / 12\" counter and a thumbnail strip. Reduced motion fades.\n */\nexport function LightboxGallery({\n  images,\n  columns = 4,\n  gap = 8,\n  aspectRatio = \"1/1\",\n  rounded = 10,\n  thumbnails = true,\n  loop = true,\n  maxZoom = 3,\n  duration = 380,\n  backdrop = \"dark\",\n  label = \"Gallery\",\n  onIndexChange,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: LightboxGalleryProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const count = images.length;\n  const [open, setOpen] = React.useState(false);\n  const [index, setIndex] = React.useState(0);\n  const [vp, setVp] = React.useState({ w: 1280, h: 800 });\n  const [loaded, setLoaded] = React.useState<Record<number, { w: number; h: number }>>({});\n  const [zoomed, setZoomed] = React.useState(false);\n\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const backdropRef = React.useRef<HTMLDivElement>(null);\n  const chromeRef = React.useRef<HTMLDivElement>(null);\n  const slideRef = React.useRef<HTMLDivElement>(null);\n  const stripRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n  const frames = React.useRef(new Map<number, HTMLDivElement>());\n  const zoomEls = React.useRef(new Map<number, HTMLDivElement>());\n  const thumbs = React.useRef(new Map<number, HTMLButtonElement>());\n  const phase = React.useRef<\"closed\" | \"opening\" | \"open\" | \"closing\">(\"closed\");\n  const busy = React.useRef(false);\n  const anims = React.useRef<Animation[]>([]);\n  const zoom = React.useRef<Zoom>({ s: 1, x: 0, y: 0 });\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>());\n  const gesture = React.useRef<{\n    kind: \"none\" | \"pending\" | \"pan\" | \"swipe\" | \"dismiss\" | \"pinch\";\n    x0: number;\n    y0: number;\n    t0: number;\n    zx: number;\n    zy: number;\n    dist: number;\n    scale: number;\n  }>({ kind: \"none\", x0: 0, y0: 0, t0: 0, zx: 0, zy: 0, dist: 1, scale: 1 });\n  const lastTap = React.useRef({ t: 0, x: 0, y: 0 });\n  // A navigation requested while the track is still sliding runs right after it lands.\n  const queued = React.useRef<{ to: number; dir: 1 | -1 } | { step: 1 | -1 } | null>(null);\n\n  const dimOf = (i: number) => {\n    const img = images[i];\n    return loaded[i] ?? { w: img?.width ?? 4, h: img?.height ?? 3 };\n  };\n  const wrap = (i: number) => (loop ? (i + count) % count : i);\n  const canGo = (dir: 1 | -1) => count > 1 && (loop || (index + dir >= 0 && index + dir < count));\n\n  /* ------------------------------- zoom ------------------------------- */\n\n  const applyZoom = (animateFrom?: Zoom) => {\n    const el = zoomEls.current.get(index);\n    if (!el) return;\n    const z = zoom.current;\n    const to = `translate(${r2(z.x)}px, ${r2(z.y)}px) scale(${r2(z.s)})`;\n    el.style.transform = to;\n    if (animateFrom && !reduceMotion && typeof el.animate === \"function\") {\n      el.animate([{ transform: `translate(${animateFrom.x}px, ${animateFrom.y}px) scale(${animateFrom.s})` }, { transform: to }], { duration: 260, easing: EASE });\n    }\n  };\n\n  /** Keeps the zoomed image covering its own box. */\n  const clampZoom = (z: Zoom): Zoom => {\n    const frame = frames.current.get(index);\n    const w = frame?.offsetWidth ?? 0;\n    const h = frame?.offsetHeight ?? 0;\n    return { s: z.s, x: clamp(z.x, w - w * z.s, 0), y: clamp(z.y, h - h * z.s, 0) };\n  };\n\n  /** Zooms to `s` keeping the viewport point (cx, cy) fixed under the pointer. */\n  const zoomAt = (s: number, cx: number, cy: number, animate: boolean) => {\n    const frame = frames.current.get(index);\n    if (!frame) return;\n    const rect = frame.getBoundingClientRect();\n    const from = { ...zoom.current };\n    const next = clamp(s, 1, maxZoom);\n    const px = cx - rect.left;\n    const py = cy - rect.top;\n    const k = next / from.s;\n    zoom.current = clampZoom({ s: next, x: px - (px - from.x) * k, y: py - (py - from.y) * k });\n    applyZoom(animate ? from : undefined);\n    const isZoomed = zoom.current.s > 1.01;\n    if (isZoomed !== zoomed) setZoomed(isZoomed);\n  };\n\n  const resetZoom = (animate: boolean) => {\n    const from = { ...zoom.current };\n    zoom.current = { s: 1, x: 0, y: 0 };\n    applyZoom(animate && from.s > 1 ? from : undefined);\n    if (zoomed) setZoomed(false);\n  };\n\n  const toggleZoomAt = (cx: number, cy: number) => {\n    if (zoom.current.s > 1.01) resetZoom(true);\n    else zoomAt(Math.min(2.5, maxZoom), cx, cy, true);\n  };\n\n  /* ----------------------------- navigation ----------------------------- */\n\n  const goTo = (next: number, dir: 1 | -1) => {\n    if (busy.current) {\n      queued.current = { to: next, dir };\n      return;\n    }\n    if (next === index || next < 0 || next >= count) return;\n    const slide = slideRef.current;\n    resetZoom(false);\n    if (!slide || reduceMotion || typeof slide.animate !== \"function\") {\n      setIndex(next);\n      onIndexChange?.(next, true);\n      return;\n    }\n    busy.current = true;\n    const current = translateOf(slide).x;\n    const target = -dir * vp.w;\n    slide.style.transform = `translateX(${target}px)`;\n    const a = slide.animate([{ transform: `translateX(${current}px)` }, { transform: `translateX(${target}px)` }], { duration: 300, easing: EASE });\n    a.onfinish = () => {\n      busy.current = false;\n      setIndex(next);\n      onIndexChange?.(next, true);\n    };\n  };\n  const step = (dir: 1 | -1) => {\n    if (busy.current) {\n      queued.current = { step: dir };\n      return;\n    }\n    if (!canGo(dir)) return;\n    goTo(wrap(index + dir), dir);\n  };\n  const runQueued = React.useRef(() => {});\n  React.useLayoutEffect(() => {\n    runQueued.current = () => {\n      const q = queued.current;\n      queued.current = null;\n      if (!q || phase.current !== \"open\") return;\n      if (\"step\" in q) step(q.step);\n      else goTo(q.to, q.dir);\n    };\n  });\n\n  // After an index change the neighbours re-slot around the new image: reset the slide in the same frame.\n  React.useLayoutEffect(() => {\n    const slide = slideRef.current;\n    if (!slide) return;\n    slide.style.transform = \"translateX(0px)\";\n    zoom.current = { s: 1, x: 0, y: 0 };\n    const el = zoomEls.current.get(index);\n    if (el) el.style.transform = \"\";\n    if (reduceMotion && phase.current === \"open\") frames.current.get(index)?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160 });\n    if (queued.current) requestAnimationFrame(() => runQueued.current());\n  }, [index, reduceMotion]);\n\n  // Keep the current thumbnail in the strip centred (scrollLeft, never scrollIntoView).\n  React.useEffect(() => {\n    const strip = stripRef.current;\n    if (!open || !strip) return;\n    const btn = strip.querySelector<HTMLElement>(`[data-strip=\"${index}\"]`);\n    if (!btn) return;\n    strip.scrollTo({ left: btn.offsetLeft - strip.clientWidth / 2 + btn.offsetWidth / 2, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n  }, [index, open, reduceMotion]);\n\n  // Preload the images two steps either side (the direct neighbours are already rendered).\n  React.useEffect(() => {\n    if (!open) return;\n    for (const d of [-2, 2]) {\n      const i = loop ? (index + d + count) % count : index + d;\n      if (i >= 0 && i < count) {\n        const img = new Image();\n        img.src = images[i].src;\n      }\n    }\n  }, [open, index, images, count, loop]);\n\n  /* ---------------------------- open / close ---------------------------- */\n\n  const thumbImg = (i: number) => thumbs.current.get(i)?.querySelector(\"img\") ?? null;\n\n  /** Keyframe that lays the full-size frame exactly over thumbnail `i`, cropped like it. */\n  const thumbFrame = (i: number): Keyframe | null => {\n    const frame = frames.current.get(i);\n    const thumb = thumbImg(i);\n    if (!frame || !thumb) return null;\n    const t = thumb.getBoundingClientRect();\n    if (t.bottom < 0 || t.top > window.innerHeight || t.width === 0) return null;\n    const fw = frame.offsetWidth || 1;\n    const fh = frame.offsetHeight || 1;\n    const s = Math.max(t.width / fw, t.height / fh);\n    const ix = Math.max(0, (fw - t.width / s) / 2);\n    const iy = Math.max(0, (fh - t.height / s) / 2);\n    const dx = t.left + t.width / 2 - (frame.offsetLeft + fw / 2);\n    const dy = t.top + t.height / 2 - (frame.offsetTop + fh / 2);\n    return { transform: `translate(${r2(dx)}px, ${r2(dy)}px) scale(${r2(s * 10000) / 10000})`, clipPath: `inset(${r2(iy)}px ${r2(ix)}px round ${r2(rounded / s)}px)` };\n  };\n  const restFrame: Keyframe = { transform: \"translate(0px, 0px) scale(1)\", clipPath: \"inset(0px 0px round 4px)\" };\n\n  const openAt = (i: number) => {\n    if (phase.current !== \"closed\") return;\n    phase.current = \"opening\";\n    setVp({ w: document.documentElement.clientWidth, h: window.innerHeight });\n    setIndex(i);\n    setOpen(true);\n    onIndexChange?.(i, true);\n  };\n\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!open || !dialog || dialog.open) return;\n    dialog.showModal();\n    closeRef.current?.focus({ preventScroll: true });\n    const frame = frames.current.get(index);\n    const thumb = thumbImg(index);\n    const from = reduceMotion ? null : thumbFrame(index);\n    const timing = { duration, easing: EASE };\n    anims.current.forEach((a) => a.cancel());\n    anims.current = [];\n    if (frame && from) {\n      if (thumb) thumb.style.visibility = \"hidden\";\n      const a = frame.animate([from, restFrame], timing);\n      a.onfinish = () => {\n        if (thumb) thumb.style.visibility = \"\";\n        if (phase.current === \"opening\") phase.current = \"open\";\n      };\n      anims.current.push(a);\n    } else {\n      phase.current = \"open\";\n      frame?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\n    }\n    backdropRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: reduceMotion ? 180 : duration * 0.8, easing: \"ease-out\" });\n    chromeRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: reduceMotion ? 180 : duration * 0.6, delay: reduceMotion ? 0 : duration * 0.4, fill: \"backwards\" });\n    // Runs once per open; index / sizes at that moment are what we animate from.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const finishClose = () => {\n    anims.current.forEach((a) => a.cancel());\n    anims.current = [];\n    thumbs.current.forEach((b) => {\n      const img = b.querySelector(\"img\");\n      if (img) img.style.visibility = \"\";\n    });\n    phase.current = \"closed\";\n    if (backdropRef.current) backdropRef.current.style.opacity = \"\";\n    if (slideRef.current) slideRef.current.style.transform = \"translateX(0px)\";\n    if (dialogRef.current?.open) dialogRef.current.close();\n    setOpen(false);\n    setZoomed(false);\n    zoom.current = { s: 1, x: 0, y: 0 };\n    onIndexChange?.(index, false);\n    // Back to the thumbnail of the image that was showing.\n    thumbs.current.get(index)?.focus({ preventScroll: true });\n  };\n\n  const close = () => {\n    if (phase.current === \"closed\" || phase.current === \"closing\") return;\n    phase.current = \"closing\";\n    queued.current = null;\n    anims.current.forEach((a) => a.cancel());\n    resetZoom(false);\n    const frame = frames.current.get(index);\n    const to = reduceMotion ? null : thumbFrame(index);\n    const slide = slideRef.current;\n    const offsetY = slide ? translateOf(slide).y : 0;\n    const timing = { duration: reduceMotion ? 160 : duration * 0.85, easing: EASE, fill: \"forwards\" as const };\n    const out: Animation[] = [];\n    if (chromeRef.current) out.push(chromeRef.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: \"forwards\" }));\n    if (backdropRef.current) out.push(backdropRef.current.animate([{ opacity: getComputedStyle(backdropRef.current).opacity }, { opacity: 0 }], timing));\n    if (frame && to) {\n      const thumb = thumbImg(index);\n      if (thumb) thumb.style.visibility = \"hidden\";\n      // A swipe-down may have moved the slide: fold that offset into the start keyframe.\n      if (slide) slide.style.transform = \"translateX(0px)\";\n      out.push(frame.animate([{ ...restFrame, transform: `translate(0px, ${r2(offsetY)}px) scale(1)` }, to], timing));\n    } else if (frame) {\n      out.push(frame.animate([{ opacity: 1 }, { opacity: 0, transform: reduceMotion ? \"none\" : \"scale(0.96)\" }], timing));\n    }\n    anims.current = out;\n    if (!out.length) finishClose();\n    else out[out.length - 1].onfinish = finishClose;\n  };\n\n  // Resize: re-fit everything. Non-passive wheel on the stage: zoom / pan instead of page scroll.\n  React.useEffect(() => {\n    if (!open) return;\n    const onResize = () => setVp({ w: document.documentElement.clientWidth, h: window.innerHeight });\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  }, [open]);\n\n  const wheelRef = React.useRef<(event: WheelEvent) => void>(() => {});\n  React.useEffect(() => {\n    wheelRef.current = (event: WheelEvent) => {\n      event.preventDefault();\n      if (phase.current !== \"open\") return;\n      if (event.ctrlKey || event.metaKey) {\n        zoomAt(zoom.current.s * Math.exp(-event.deltaY * 0.01), event.clientX, event.clientY, false);\n      } else if (zoom.current.s > 1.01) {\n        zoom.current = clampZoom({ ...zoom.current, x: zoom.current.x - event.deltaX, y: zoom.current.y - event.deltaY });\n        applyZoom();\n      }\n    };\n  });\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!open || !dialog) return;\n    const onWheel = (event: WheelEvent) => {\n      if ((event.target as HTMLElement).closest(\"[data-lb-strip]\")) return;\n      wheelRef.current(event);\n    };\n    dialog.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => dialog.removeEventListener(\"wheel\", onWheel);\n  }, [open]);\n\n  React.useEffect(\n    () => () => {\n      anims.current.forEach((a) => a.cancel());\n      if (dialogRef.current?.open) dialogRef.current.close();\n    },\n    [],\n  );\n\n  /* ------------------------------ gestures ------------------------------ */\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0 || phase.current !== \"open\" || busy.current) return;\n    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const g = gesture.current;\n    if (pointers.current.size === 2) {\n      const [a, b] = [...pointers.current.values()];\n      if (slideRef.current) slideRef.current.style.transform = \"translateX(0px)\";\n      gesture.current = { ...g, kind: \"pinch\", dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, scale: zoom.current.s };\n    } else if (pointers.current.size === 1) {\n      gesture.current = { kind: \"pending\", x0: event.clientX, y0: event.clientY, t0: performance.now(), zx: zoom.current.x, zy: zoom.current.y, dist: 1, scale: zoom.current.s };\n    }\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(event.pointerId)) return;\n    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });\n    const g = gesture.current;\n    const slide = slideRef.current;\n    if (g.kind === \"pinch\" && pointers.current.size >= 2) {\n      const [a, b] = [...pointers.current.values()];\n      const dist = Math.hypot(a.x - b.x, a.y - b.y);\n      zoomAt(g.scale * (dist / g.dist), (a.x + b.x) / 2, (a.y + b.y) / 2, false);\n      return;\n    }\n    const dx = event.clientX - g.x0;\n    const dy = event.clientY - g.y0;\n    if (g.kind === \"pending\" && Math.hypot(dx, dy) > 8) {\n      g.kind = zoom.current.s > 1.01 ? \"pan\" : Math.abs(dx) > Math.abs(dy) ? \"swipe\" : dy > 0 ? \"dismiss\" : \"none\";\n    }\n    if (g.kind === \"pan\") {\n      zoom.current = clampZoom({ s: zoom.current.s, x: g.zx + dx, y: g.zy + dy });\n      applyZoom();\n    } else if (g.kind === \"swipe\" && slide) {\n      // Rubber-band at the ends when not looping.\n      const blocked = (dx > 0 && !canGo(-1)) || (dx < 0 && !canGo(1));\n      slide.style.transform = `translateX(${r2(blocked ? dx * 0.25 : dx)}px)`;\n    } else if (g.kind === \"dismiss\" && slide) {\n      slide.style.transform = `translate(0px, ${r2(Math.max(0, dy))}px)`;\n      if (backdropRef.current) backdropRef.current.style.opacity = String(r2(1 - Math.min(0.7, dy / 400)));\n    }\n  };\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(event.pointerId)) return;\n    pointers.current.delete(event.pointerId);\n    const g = gesture.current;\n    const slide = slideRef.current;\n    if (g.kind === \"pinch\") {\n      if (pointers.current.size === 1) {\n        // One finger left: continue as a pan from here.\n        const [p] = [...pointers.current.values()];\n        gesture.current = { ...g, kind: \"pan\", x0: p.x, y0: p.y, zx: zoom.current.x, zy: zoom.current.y };\n      } else if (pointers.current.size === 0) {\n        gesture.current = { ...g, kind: \"none\" };\n        if (zoom.current.s < 1.05) resetZoom(true);\n      }\n      return;\n    }\n    if (pointers.current.size > 0) return;\n    const dx = event.clientX - g.x0;\n    const dy = event.clientY - g.y0;\n    const dt = Math.max(1, performance.now() - g.t0);\n    gesture.current = { ...g, kind: \"none\" };\n    if (g.kind === \"swipe\" && slide) {\n      const dir: 1 | -1 = dx < 0 ? 1 : -1;\n      if ((Math.abs(dx) > vp.w * 0.18 || Math.abs(dx) / dt > 0.5) && canGo(dir)) {\n        step(dir);\n      } else {\n        const a = slide.animate([{ transform: slide.style.transform }, { transform: \"translateX(0px)\" }], { duration: 220, easing: EASE });\n        slide.style.transform = \"translateX(0px)\";\n        anims.current.push(a);\n      }\n    } else if (g.kind === \"dismiss\" && slide) {\n      if (dy > 120 || dy / dt > 0.6) close();\n      else {\n        slide.animate([{ transform: slide.style.transform }, { transform: \"translate(0px, 0px)\" }], { duration: 220, easing: EASE });\n        slide.style.transform = \"translateX(0px)\";\n        if (backdropRef.current) backdropRef.current.style.opacity = \"\";\n      }\n    } else if (g.kind === \"pending\") {\n      const frame = frames.current.get(index);\n      const r = frame?.getBoundingClientRect();\n      const onImage = !!r && event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom;\n      if (event.pointerType !== \"mouse\") {\n        // Double-tap toggles zoom; a single tap on the backdrop closes.\n        const now = performance.now();\n        const t = lastTap.current;\n        if (now - t.t < 300 && Math.hypot(event.clientX - t.x, event.clientY - t.y) < 30) {\n          lastTap.current = { t: 0, x: 0, y: 0 };\n          if (onImage) toggleZoomAt(event.clientX, event.clientY);\n          return;\n        }\n        lastTap.current = { t: now, x: event.clientX, y: event.clientY };\n      }\n      if (!onImage && zoom.current.s <= 1.01) close();\n    }\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {\n    if (event.altKey || event.ctrlKey || event.metaKey || phase.current !== \"open\") return;\n    const z = zoom.current;\n    const panBy = (x: number, y: number) => {\n      const from = { ...z };\n      zoom.current = clampZoom({ ...z, x: z.x + x, y: z.y + y });\n      applyZoom(from);\n    };\n    const centre = () => {\n      const r = frames.current.get(index)?.getBoundingClientRect();\n      return r ? [r.left + r.width / 2, r.top + r.height / 2] : [vp.w / 2, vp.h / 2];\n    };\n    switch (event.key) {\n      case \"ArrowRight\":\n        if (z.s > 1.01) panBy(-80, 0);\n        else step(1);\n        break;\n      case \"ArrowLeft\":\n        if (z.s > 1.01) panBy(80, 0);\n        else step(-1);\n        break;\n      case \"ArrowUp\":\n        if (z.s <= 1.01) return;\n        panBy(0, 80);\n        break;\n      case \"ArrowDown\":\n        if (z.s <= 1.01) return;\n        panBy(0, -80);\n        break;\n      case \"Home\":\n        goTo(0, -1);\n        break;\n      case \"End\":\n        goTo(count - 1, 1);\n        break;\n      case \"+\":\n      case \"=\": {\n        const [cx, cy] = centre();\n        zoomAt(z.s * 1.5, cx, cy, true);\n        break;\n      }\n      case \"-\": {\n        const [cx, cy] = centre();\n        if (z.s / 1.5 <= 1.01) resetZoom(true);\n        else zoomAt(z.s / 1.5, cx, cy, true);\n        break;\n      }\n      case \"0\":\n        resetZoom(true);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n  };\n\n  /* -------------------------------- render ------------------------------- */\n\n  const dark = backdrop === \"dark\";\n  const chromeText = dark ? \"text-zinc-100\" : \"text-zinc-800 dark:text-zinc-100\";\n  const chromeButton = `grid size-10 place-items-center rounded-full outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] disabled:opacity-30 [&_svg]:size-[18px] ${\n    dark ? \"bg-white/[0.06] text-zinc-100 hover:bg-white/15\" : \"bg-zinc-900/[0.05] text-zinc-800 hover:bg-zinc-900/10 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:bg-white/15\"\n  }`;\n  const current = images[index];\n\n  // Current image plus its neighbours, each in its own viewport-wide slot.\n  const slots: [number, number][] = [];\n  if (open && count) {\n    slots.push([index, 0]);\n    const next = loop ? (index + 1) % count : index + 1;\n    const prev = loop ? (index - 1 + count) % count : index - 1;\n    if (next !== index && next < count) slots.push([next, 1]);\n    if (prev !== index && prev >= 0 && prev !== next) slots.push([prev, -1]);\n  }\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--lb-accent\" as string]: accent, ...style }} {...props}>\n      <ul\n        aria-label={label}\n        className=\"m-0 grid list-none grid-cols-2 p-0 sm:grid-cols-[repeat(var(--lb-cols),minmax(0,1fr))]\"\n        style={{ gap, [\"--lb-cols\" as string]: Math.max(1, Math.round(columns)) }}\n      >\n        {images.map((img, i) => (\n          <li key={i} className=\"min-w-0\">\n            <button\n              ref={(el) => {\n                if (!el) return;\n                const map = thumbs.current;\n                map.set(i, el);\n                return () => {\n                  if (map.get(i) === el) map.delete(i);\n                };\n              }}\n              type=\"button\"\n              aria-haspopup=\"dialog\"\n              onClick={() => openAt(i)}\n              className=\"group/thumb relative block w-full cursor-zoom-in overflow-hidden bg-zinc-900/[0.04] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] dark:bg-white/[0.04]\"\n              style={{ borderRadius: rounded, aspectRatio: aspectRatio.replace(\"/\", \" / \") }}\n            >\n              {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n              <img\n                src={img.thumbSrc ?? img.src}\n                alt={img.alt}\n                width={img.width}\n                height={img.height}\n                loading=\"lazy\"\n                decoding=\"async\"\n                draggable={false}\n                className=\"block size-full object-cover transition-transform duration-500 ease-[cubic-bezier(0.2,0.9,0.1,1)] [@media(hover:hover)]:group-hover/thumb:scale-[1.04]\"\n              />\n              <span className=\"sr-only\">{`, image ${i + 1} of ${count}, open viewer`}</span>\n            </button>\n          </li>\n        ))}\n      </ul>\n\n      <dialog\n        ref={dialogRef}\n        aria-label={`${label} viewer`}\n        onCancel={(event) => {\n          event.preventDefault();\n          close();\n        }}\n        onClose={() => {\n          if (phase.current !== \"closed\") finishClose();\n        }}\n        onKeyDown={onKeyDown}\n        className=\"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-left outline-hidden backdrop:bg-transparent\"\n        style={{ [\"--lb-accent\" as string]: accent }}\n      >\n        <div ref={backdropRef} aria-hidden=\"true\" className={`absolute inset-0 ${dark ? \"bg-[#0b0d0f]/95\" : \"bg-[#F5F3EE]/95 dark:bg-[#0b0d0f]/95\"}`} />\n\n        {/* Stage: gestures land here; images sit in viewport-wide slots on a sliding track. */}\n        <div\n          className={`absolute inset-0 touch-none select-none ${zoomed ? \"cursor-grab active:cursor-grabbing\" : \"\"}`}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          onDoubleClick={(event) => {\n            const r = frames.current.get(index)?.getBoundingClientRect();\n            if (r && event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom) toggleZoomAt(event.clientX, event.clientY);\n          }}\n        >\n          <div ref={slideRef} className=\"absolute inset-0 will-change-transform\">\n            {slots.map(([i, slot]) => {\n              const img = images[i];\n              const box = fit(vp, dimOf(i), thumbnails);\n              return (\n                <div\n                  key={i}\n                  ref={(el) => {\n                    if (!el) return;\n                    const map = frames.current;\n                    map.set(i, el);\n                    return () => {\n                      if (map.get(i) === el) map.delete(i);\n                    };\n                  }}\n                  aria-hidden={slot !== 0 || undefined}\n                  className={`absolute origin-center ${slot === 0 ? \"\" : \"pointer-events-none\"}`}\n                  style={{ left: r2(box.left + slot * vp.w), top: box.top, width: box.width, height: box.height }}\n                >\n                  <div\n                    ref={(el) => {\n                      if (!el) return;\n                      const map = zoomEls.current;\n                      map.set(i, el);\n                      return () => {\n                        if (map.get(i) === el) map.delete(i);\n                      };\n                    }}\n                    className=\"size-full origin-top-left\"\n                  >\n                    {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n                    <img\n                      src={img.src}\n                      alt={slot === 0 ? img.alt : \"\"}\n                      draggable={false}\n                      decoding=\"async\"\n                      onLoad={(event) => {\n                        const { naturalWidth: w, naturalHeight: h } = event.currentTarget;\n                        if (w && h && (!img.width || !img.height)) setLoaded((prev) => (prev[i] ? prev : { ...prev, [i]: { w, h } }));\n                      }}\n                      className=\"block size-full rounded-[4px] shadow-[0_30px_80px_-30px_rgb(0_0_0/0.6)]\"\n                    />\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n\n        {/* Chrome: counter, zoom, close, arrows, caption and the thumbnail strip. */}\n        <div ref={chromeRef} className={`pointer-events-none absolute inset-0 ${chromeText}`}>\n          <div className=\"absolute inset-x-0 top-0 flex h-[60px] items-center justify-between gap-3 px-3 sm:px-5\">\n            <p className=\"font-mono text-[11px] uppercase tabular-nums tracking-[0.14em] opacity-80\" aria-hidden=\"true\">\n              {index + 1} <span className=\"opacity-50\">/</span> {count}\n            </p>\n            <div className=\"pointer-events-auto flex items-center gap-1.5\">\n              <button\n                type=\"button\"\n                className={chromeButton}\n                aria-label={zoomed ? \"Zoom out\" : \"Zoom in\"}\n                onClick={() => {\n                  const r = frames.current.get(index)?.getBoundingClientRect();\n                  toggleZoomAt(r ? r.left + r.width / 2 : vp.w / 2, r ? r.top + r.height / 2 : vp.h / 2);\n                }}\n              >\n                <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">\n                  <circle cx=\"8.5\" cy=\"8.5\" r=\"5.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n                  <path d=\"M12.5 12.5 17 17M6.2 8.5h4.6\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n                  {zoomed ? null : <path d=\"M8.5 6.2v4.6\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />}\n                </svg>\n              </button>\n              <button ref={closeRef} type=\"button\" className={chromeButton} aria-label=\"Close viewer\" onClick={close}>\n                <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">\n                  <path d=\"M5 5l10 10M15 5 5 15\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n                </svg>\n              </button>\n            </div>\n          </div>\n\n          {count > 1 ? (\n            <>\n              <button\n                type=\"button\"\n                className={`${chromeButton} pointer-events-auto absolute left-3 top-1/2 -translate-y-1/2 max-sm:hidden ${zoomed ? \"opacity-0\" : \"\"}`}\n                aria-label=\"Previous image\"\n                disabled={!canGo(-1)}\n                onClick={() => step(-1)}\n              >\n                <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">\n                  <path d=\"M12 4.5 6.5 10l5.5 5.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </svg>\n              </button>\n              <button\n                type=\"button\"\n                className={`${chromeButton} pointer-events-auto absolute right-3 top-1/2 -translate-y-1/2 max-sm:hidden ${zoomed ? \"opacity-0\" : \"\"}`}\n                aria-label=\"Next image\"\n                disabled={!canGo(1)}\n                onClick={() => step(1)}\n              >\n                <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">\n                  <path d=\"M8 4.5 13.5 10 8 15.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </svg>\n              </button>\n            </>\n          ) : null}\n\n          <div className={`absolute inset-x-0 bottom-0 transition-opacity duration-200 ${zoomed ? \"opacity-0\" : \"\"}`}>\n            <p className=\"mx-auto flex h-12 max-w-[min(92vw,720px)] items-center justify-center px-4 text-center text-[13.5px] leading-snug tracking-[-0.005em] opacity-90\">\n              <span className=\"line-clamp-2\">{current?.caption ?? \"\"}</span>\n            </p>\n            {thumbnails && count > 1 ? (\n              <div\n                ref={stripRef}\n                data-lb-strip=\"\"\n                className={`pointer-events-auto mx-auto flex h-[84px] max-w-full items-center gap-2 overflow-x-auto overscroll-contain px-[50vw] pb-3 [scrollbar-width:none] ${zoomed ? \"pointer-events-none\" : \"\"}`}\n              >\n                {images.map((img, i) => (\n                  <button\n                    key={i}\n                    type=\"button\"\n                    data-strip={i}\n                    aria-label={`Image ${i + 1}: ${img.alt}`}\n                    aria-current={i === index ? \"true\" : undefined}\n                    tabIndex={zoomed ? -1 : 0}\n                    onClick={() => goTo(i, i > index ? 1 : -1)}\n                    className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-md outline-hidden transition-[opacity,scale] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] ${\n                      i === index ? \"scale-105 opacity-100 ring-2 ring-[var(--lb-accent)]\" : \"opacity-45 hover:opacity-80\"\n                    }`}\n                  >\n                    {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n                    <img src={img.thumbSrc ?? img.src} alt=\"\" draggable={false} loading=\"lazy\" className=\"block size-full object-cover\" />\n                  </button>\n                ))}\n              </div>\n            ) : null}\n          </div>\n        </div>\n\n        <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\" id={`${uid}-status`}>\n          {open && current\n            ? [`Image ${index + 1} of ${count}`, current.caption, zoomed ? \"Zoomed in\" : \"\"]\n                .filter(Boolean)\n                .map((part) => (/[.!?]$/.test(part as string) ? part : `${part}.`))\n                .join(\" \")\n            : \"\"}\n        </p>\n      </dialog>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/lightbox-gallery"
}
