{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lightbox","type":"registry:ui","title":"Lightbox","description":"A grid of pictures that opens each one large, with arrows, swipes and captions.","dependencies":["lucide-react","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-portal.json","https://dashral.com/r/image.json","https://dashral.com/r/spinner.json"],"files":[{"path":"registry/dashral/lightbox.tsx","type":"registry:file","target":"components/dashral/lightbox.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Dialog as DialogPrimitive } from \"radix-ui\"\nimport { ChevronLeftIcon, ChevronRightIcon, ExpandIcon, XIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { usePortalContainer } from \"./lib/portal\"\nimport { Image as LoadingImage } from \"./image\"\nimport { Spinner } from \"./spinner\"\n\ntype LightboxImage = {\n  src: string\n  /** Describes the picture; also the thumbnail's name. Required for anything that isn't decoration. */\n  alt: string\n  /** A smaller file for the thumbnail; src without one. */\n  thumbnail?: string\n  /** Under the picture in the viewer. */\n  caption?: React.ReactNode\n  width?: number\n  height?: number\n}\n\ntype LightboxProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  images: LightboxImage[]\n  /**\n   * \"grid\" (the default): a grid of thumbnails, each opening the viewer. \"gallery\": one large cover with a strip of\n   * thumbnails under it, as a product's photos are shown; a thumbnail shows its picture in the cover, and the cover\n   * opens the viewer.\n   */\n  layout?: \"grid\" | \"gallery\"\n  /** The thumbnails' columns: from the sm breakpoint up in a grid (two on phones; 3 by default), always in a gallery's strip (4 by default). */\n  columns?: 2 | 3 | 4 | 5\n  /** The thumbnails' shape, and a gallery's cover: width over height. */\n  ratio?: number\n  /** false: no grid; open it yourself with open, onOpenChange and index (e.g. from a carousel or a single cover). */\n  thumbnails?: boolean\n  /** Whether the viewer is open, controlled. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** The picture in the viewer, controlled (from 0). */\n  index?: number\n  onIndexChange?: (index: number) => void\n  /** Your image component (Next.js Image) for the thumbnails and the viewer; it gets src, alt, width, height, className. */\n  imageAs?: React.ElementType\n  classNames?: { grid?: string; thumbnail?: string; cover?: string; viewer?: string; image?: string; caption?: string }\n}\n\nconst columnClasses = { 2: \"sm:grid-cols-2\", 3: \"sm:grid-cols-3\", 4: \"sm:grid-cols-4\", 5: \"sm:grid-cols-5\" }\n\nconst navButton =\n  \"grid size-10 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur-sm transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 disabled:pointer-events-none disabled:opacity-30 [&_svg]:size-5\"\n\n/**\n * A grid of pictures that opens each one large, in one tag: `<Lightbox images={photos} />`. In the viewer the arrow\n * keys and the buttons move between them (in reading order, so the other way in Arabic), a swipe does on a phone, and\n * Escape closes it, back to the thumbnail it came from. `layout=\"gallery\"` shows them as a product's photos: one\n * large cover and a strip of thumbnails under it. A picture that's still on its way says so (a breathing tile, a\n * small spinner) and fades in when it's there; nothing pops in.\n */\nfunction Lightbox({\n  images,\n  layout = \"grid\",\n  columns: columnsProp,\n  ratio = 4 / 3,\n  thumbnails = true,\n  open: openProp,\n  onOpenChange,\n  index: indexProp,\n  onIndexChange,\n  imageAs: Image = \"img\",\n  className,\n  classNames,\n  ...props\n}: LightboxProps) {\n  const { t, dir } = useLocale()\n  const portalContainer = usePortalContainer()\n  const [openState, setOpenState] = React.useState(false)\n  const [indexState, setIndexState] = React.useState(0)\n  const open = openProp ?? openState\n  const count = images.length\n  const index = Math.min(Math.max(indexProp ?? indexState, 0), Math.max(count - 1, 0))\n  const image = images[index]\n  const swipe = React.useRef<{ x: number; y: number } | null>(null)\n\n  function setOpen(next: boolean) {\n    if (openProp === undefined) setOpenState(next)\n    onOpenChange?.(next)\n  }\n  function go(next: number) {\n    if (count === 0) return\n    const wrapped = (next + count) % count\n    if (indexProp === undefined) setIndexState(wrapped)\n    onIndexChange?.(wrapped)\n  }\n  // Physical arrows, mapped to reading order: → is \"next\" left to right, \"previous\" right to left.\n  const forward = dir === \"rtl\" ? -1 : 1\n  const columns = columnsProp ?? (layout === \"gallery\" ? 4 : 3)\n\n  return (\n    <>\n      {thumbnails && layout === \"gallery\" && image && (\n        <div data-slot=\"lightbox\" className={cn(\"grid gap-2\", className)} {...props}>\n          <button\n            type=\"button\"\n            aria-label={t(\"viewImage\", { name: image.alt })}\n            aria-haspopup=\"dialog\"\n            onClick={() => setOpen(true)}\n            className={cn(\n              \"group/cover relative overflow-hidden rounded-xl bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n              classNames?.cover\n            )}\n            style={{ aspectRatio: ratio }}\n          >\n            {/* A thumbnail picked answers at once: a spinner over the picture showing until the new one fades in. */}\n            <LoadingImage imageAs={Image} fill src={image.src} alt=\"\" width={image.width} height={image.height} className=\"size-full\" />\n            <span\n              aria-hidden\n              className=\"absolute end-2 bottom-2 grid size-8 place-items-center rounded-md bg-black/50 text-white opacity-0 transition-opacity duration-(--duration-fast) group-hover/cover:opacity-100 group-focus-visible/cover:opacity-100\"\n            >\n              <ExpandIcon className=\"size-4\" />\n            </span>\n          </button>\n          {count > 1 && (\n            <div className={cn(\"grid gap-2\", classNames?.grid)} style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>\n              {images.map((entry, i) => (\n                <button\n                  key={entry.src + i}\n                  type=\"button\"\n                  aria-label={entry.alt}\n                  aria-current={i === index ? \"true\" : undefined}\n                  onClick={() => go(i)}\n                  className={cn(\n                    \"group/thumb relative overflow-hidden rounded-md bg-muted outline-none transition-opacity duration-(--duration-fast) focus-visible:ring-2 focus-visible:ring-ring/50\",\n                    i === index ? \"ring-2 ring-primary\" : \"opacity-60 hover:opacity-100\",\n                    classNames?.thumbnail\n                  )}\n                  style={{ aspectRatio: ratio }}\n                >\n                  <LoadingImage\n                    imageAs={Image}\n                    fill\n                    src={entry.thumbnail ?? entry.src}\n                    alt=\"\"\n                    width={entry.width}\n                    height={entry.height}\n                    loading=\"lazy\"\n                    className=\"size-full\"\n                    classNames={{ image: \"motion-safe:group-hover/thumb:scale-105\" }}\n                  />\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n      {thumbnails && layout === \"grid\" && (\n        <div\n          data-slot=\"lightbox\"\n          className={cn(\"grid grid-cols-2 gap-2\", columnClasses[columns], className, classNames?.grid)}\n          {...props}\n        >\n          {images.map((entry, i) => (\n            <button\n              key={entry.src + i}\n              type=\"button\"\n              aria-label={t(\"viewImage\", { name: entry.alt })}\n              aria-haspopup=\"dialog\"\n              onClick={() => {\n                go(i)\n                setOpen(true)\n              }}\n              className={cn(\n                \"group/thumb relative overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                classNames?.thumbnail\n              )}\n              style={{ aspectRatio: ratio }}\n            >\n              <LoadingImage\n                    imageAs={Image}\n                    fill\n                    src={entry.thumbnail ?? entry.src}\n                    alt=\"\"\n                    width={entry.width}\n                    height={entry.height}\n                    loading=\"lazy\"\n                    className=\"size-full\"\n                    classNames={{ image: \"motion-safe:group-hover/thumb:scale-105\" }}\n                  />\n            </button>\n          ))}\n        </div>\n      )}\n      <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n        <DialogPrimitive.Portal container={portalContainer}>\n          <DialogPrimitive.Overlay className=\"fixed inset-0 z-50 bg-black/90 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-(--duration-fast) data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-(--duration-standard)\" />\n          <DialogPrimitive.Content\n            aria-describedby={undefined}\n            onKeyDown={(event) => {\n              if (event.key === \"ArrowRight\") go(index + forward)\n              else if (event.key === \"ArrowLeft\") go(index - forward)\n              else if (event.key === \"Home\") go(0)\n              else if (event.key === \"End\") go(count - 1)\n              else return\n              event.preventDefault()\n            }}\n            onPointerDown={(event) => {\n              if (event.pointerType !== \"mouse\") swipe.current = { x: event.clientX, y: event.clientY }\n            }}\n            onPointerUp={(event) => {\n              const start = swipe.current\n              swipe.current = null\n              if (!start) return\n              const dx = event.clientX - start.x\n              // A sideways swipe of 50px or more, more across than down. Swiping toward the start shows the next one.\n              if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(event.clientY - start.y)) return\n              go(index + (dx < 0 ? forward : -forward))\n            }}\n            className={cn(\n              \"fixed inset-0 z-50 flex touch-pan-y flex-col text-white outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--duration-standard) data-[state=open]:ease-spring data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-(--duration-fast)\",\n              classNames?.viewer\n            )}\n          >\n            <DialogPrimitive.Title className=\"sr-only\">{image?.alt ?? t(\"imageViewer\")}</DialogPrimitive.Title>\n            <div className=\"flex h-14 shrink-0 items-center justify-between gap-3 px-4\">\n              <span className=\"text-sm text-white/70 tabular-nums\" aria-live=\"polite\">\n                {count > 1 ? t(\"imageOf\", { index: index + 1, total: count }) : \"\"}\n              </span>\n              <DialogPrimitive.Close aria-label={t(\"close\")} className={navButton}>\n                <XIcon aria-hidden />\n              </DialogPrimitive.Close>\n            </div>\n            <div className=\"relative flex min-h-0 flex-1 items-center justify-center px-4 sm:px-20\">\n              {image && <Stage images={images} index={index} Image={Image} className={classNames?.image} />}\n              {count > 1 && (\n                <>\n                  <button type=\"button\" aria-label={t(\"previousImage\")} onClick={() => go(index - 1)} className={cn(navButton, \"absolute start-3 top-1/2 -translate-y-1/2 max-sm:hidden\")}>\n                    <ChevronLeftIcon aria-hidden className=\"rtl:-scale-x-100\" />\n                  </button>\n                  <button type=\"button\" aria-label={t(\"nextImage\")} onClick={() => go(index + 1)} className={cn(navButton, \"absolute end-3 top-1/2 -translate-y-1/2 max-sm:hidden\")}>\n                    <ChevronRightIcon aria-hidden className=\"rtl:-scale-x-100\" />\n                  </button>\n                </>\n              )}\n            </div>\n            <div className=\"flex min-h-14 shrink-0 items-center justify-center gap-3 px-4 py-3\">\n              {count > 1 && (\n                <button type=\"button\" aria-label={t(\"previousImage\")} onClick={() => go(index - 1)} className={cn(navButton, \"sm:hidden\")}>\n                  <ChevronLeftIcon aria-hidden className=\"rtl:-scale-x-100\" />\n                </button>\n              )}\n              <div\n                key={index}\n                className={cn(\n                  \"min-w-0 flex-1 animate-in text-center text-sm text-pretty text-white/80 duration-(--duration-considered) ease-standard fade-in-0 motion-reduce:animate-none sm:flex-none\",\n                  classNames?.caption\n                )}\n              >\n                {image?.caption}\n              </div>\n              {count > 1 && (\n                <button type=\"button\" aria-label={t(\"nextImage\")} onClick={() => go(index + 1)} className={cn(navButton, \"sm:hidden\")}>\n                  <ChevronRightIcon aria-hidden className=\"rtl:-scale-x-100\" />\n                </button>\n              )}\n            </div>\n          </DialogPrimitive.Content>\n        </DialogPrimitive.Portal>\n      </DialogPrimitive.Root>\n    </>\n  )\n}\n\ntype StageState = {\n  /** The picture to show, and the one it replaces (kept on screen until the new one has loaded). */\n  index: number\n  previous: number | null\n  /** 1 moving to the next picture, -1 to the previous: where the new one comes from. */\n  step: 1 | -1\n  /** Counts the changes, so each one gets fresh elements (and fresh animations). */\n  turn: number\n  loaded: boolean\n}\n\nconst cell = \"col-start-1 row-start-1 max-h-full max-w-full rounded-sm object-contain select-none\"\nconst roll = \"duration-(--duration-considered) ease-standard motion-reduce:animate-none\"\n\n/**\n * The viewer's picture. Moving to another, the one on screen stays until the new one has loaded, then the two cross:\n * the old one fades out toward the side it's left behind on, the new one fades in from the side it comes from (logical\n * sides, so it reverses in Arabic). Never a blank frame, and no picture popping in after its fade. The neighbours are\n * fetched ahead, so moving on is usually instant; when one does take a moment, a small spinner says so (after a\n * beat, so a quick picture never flashes it), over the picture still showing or on the dark when it's the first.\n */\nfunction Stage({\n  images,\n  index,\n  Image,\n  className,\n}: {\n  images: LightboxImage[]\n  index: number\n  Image: React.ElementType\n  className?: string\n}) {\n  const { t } = useLocale()\n  const count = images.length\n  const [state, setState] = React.useState<StageState>({ index, previous: null, step: 1, turn: 0, loaded: false })\n  if (state.index !== index) {\n    const delta = (index - state.index + count) % count\n    const step = delta === 1 ? 1 : delta === count - 1 ? -1 : index > state.index ? 1 : -1\n    // Pressed again before the last one loaded: the picture still on screen stays the one to leave.\n    setState({ index, previous: state.loaded ? state.index : state.previous, step, turn: state.turn + 1, loaded: false })\n  }\n  const { previous, step, turn, loaded } = state\n  const current = images[state.index]!\n  const leaving = previous === null ? undefined : images[previous]\n\n  const markLoaded = React.useCallback(\n    () => setState((s) => (s.turn === turn && !s.loaded ? { ...s, loaded: true } : s)),\n    [turn]\n  )\n\n  React.useEffect(() => {\n    if (Image !== \"img\" || count < 2) return\n    for (const i of [state.index + 1, state.index - 1]) {\n      const preload = new window.Image()\n      preload.src = images[(i + count) % count]!.src\n    }\n  }, [Image, images, count, state.index])\n\n  return (\n    <div className=\"grid size-full min-h-0 grid-cols-[minmax(0,1fr)] grid-rows-[minmax(0,1fr)] place-items-center\">\n      {leaving && (\n        <Image\n          key={`out-${turn}`}\n          src={leaving.src}\n          alt=\"\"\n          aria-hidden\n          width={leaving.width}\n          height={leaving.height}\n          draggable={false}\n          className={cn(\n            cell,\n            loaded && [\"animate-out fade-out-0 fill-mode-forwards motion-reduce:hidden\", step > 0 ? \"slide-out-to-start-8\" : \"slide-out-to-end-8\", roll],\n            className\n          )}\n        />\n      )}\n      <Image\n        key={`in-${turn}`}\n        ref={(node: HTMLImageElement | null) => {\n          // Already in the cache: it may have loaded before React was listening.\n          if (node?.complete && node.naturalWidth > 0) markLoaded()\n        }}\n        src={current.src}\n        alt={current.alt}\n        width={current.width}\n        height={current.height}\n        draggable={false}\n        onLoad={markLoaded}\n        onError={markLoaded}\n        className={cn(\n          cell,\n          !loaded ? \"opacity-0\" : [\"animate-in fade-in-0\", turn > 0 && (step > 0 ? \"slide-in-from-end-8\" : \"slide-in-from-start-8\"), roll],\n          className\n        )}\n      />\n      {!loaded && (\n        <span\n          key={`wait-${turn}`}\n          className=\"pointer-events-none z-10 col-start-1 row-start-1 grid size-10 animate-in place-items-center rounded-full bg-black/45 backdrop-blur-sm delay-200 duration-(--duration-standard) fill-mode-both fade-in-0 motion-reduce:animate-none\"\n        >\n          <Spinner size=\"sm\" label={t(\"loading\")} className=\"text-white/85\" />\n        </span>\n      )}\n    </div>\n  )\n}\n\nexport { Lightbox, type LightboxProps, type LightboxImage }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}