-
Error: Each item in the `imagesArray` needs a unique
+
+ Error: Each item in the `imagesData` needs a unique
`id`
);
});
- return imageElementsArray;
- }
-
- function changeSlide(thumbClick: boolean, step: number) {
- const totalImages = imagesData.length;
- let newSlideNumber = thumbClick ? step + 1 : slideNumber + step;
-
- newSlideNumber < 1 && (newSlideNumber = totalImages);
- newSlideNumber > totalImages && (newSlideNumber = 1);
-
- if (newSlideNumber <= totalImages && newSlideNumber > 0) {
- const imageData = imagesData[newSlideNumber - 1];
- setSlideNumber(newSlideNumber);
- setImgSrcInfo({
- src: imageData.src,
- srcSet: imageData.srcSet,
- mediaSizes: imageData.mediaSizes,
- });
- }
- }
-
- function scrollImage(
- thumbClick: boolean,
- direction: number,
- imgIndex: number,
- ) {
- const step = thumbClick ? imgIndex : direction;
- flushSync(() => changeSlide(thumbClick, step));
- activeThumbImgRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "nearest",
- inline: "center",
- });
- }
-
- function handleKeyDownOnModal(e: React.KeyboardEvent
) {
- e.key === "ArrowLeft" && scrollImage(false, -1, 0);
- e.key === "ArrowRight" && scrollImage(false, 1, 0);
- e.key === "f" && fullscreen && switchFullScreen(false);
- e.key === "f" && !fullscreen && switchFullScreen(true);
- }
-
- function exitFullScreenAndDialog() {
- fullscreen && switchFullScreen(false);
- dialogRef.current?.close();
- }
-
- function showLightBox() {
- const imageData = imagesData[slideNumber - 1];
- return (
-
- handleKeyDownOnModal(e)}
- onMouseEnter={() => setShowModalControls(true)}
- onMouseLeave={() => setShowModalControls(false)}
- onClick={(e) =>
- (e.target as HTMLElement).tagName === "SECTION" &&
- exitFullScreenAndDialog()
- }
- >
- {`${slideNumber} / ${imagesData.length}`}
-
- setShowThumbnails(!showThumbnails)}
- >
- {SvgElement(
- ,
- )}
-
- switchFullScreen(true)}
- >
- {SvgElement(
- ,
- )}
-
- switchFullScreen(false)}
- >
- {SvgElement(
- ,
- )}
-
- exitFullScreenAndDialog()}
- >
- {SvgElement(
- ,
- )}
-
-
-
- scrollImage(false, -1, 0)}
- >
- {SvgElement(
- ,
- )}
-
-
- fixedCaption ? undefined : updateCaptionOpacity(e, "1")
- }
- onMouseLeave={(e) =>
- fixedCaption ? undefined : updateCaptionOpacity(e, "0")
- }
- >
-
- {imageData.caption ? (
-
- {imageData.caption}
- {imageData?.cta?.href && imageData?.cta?.text && (
-
- )}
-
- ) : (
- ""
- )}
-
- scrollImage(false, 1, 0)}
- >
- {SvgElement(
- ,
- )}
-
-
-
-
- {imagesData.map((imageData, index) => (
-
scrollImage(true, 0, index)}
- />
- ))}
-
-
-
-
- );
}
- useEffect(() => {
- function handleFullscreenChange() {
- setFullscreen(Boolean(document.fullscreenElement));
- lightboxRef.current?.focus();
- }
- document.addEventListener("fullscreenchange", handleFullscreenChange);
- return () =>
- document.removeEventListener("fullscreenchange", handleFullscreenChange);
- }, []);
-
- useEffect(() => {
- dialogRef.current?.open &&
- (document.documentElement.style.overflow = "hidden");
- !dialogRef.current?.open && (document.documentElement.style.overflow = "");
- });
-
return (
{showImageCards()}
- {enableDefaultLightbox && showLightBox()}
+ {enableDefaultLightbox && (
+ setLightboxOpen(false)}
+ fixedCaption={fixedCaption}
+ imagesData={imagesData}
+ initialSlideNumber={slideNumber}
+ isOpen={lightboxOpen}
+ lazy={lazy}
+ setShowThumbnails={setShowThumbnails}
+ showThumbnails={showThumbnails}
+ thumbnailBorder={thumbnailBorder}
+ />
+ )}
);
}
diff --git a/src/ImageGallery.types.tsx b/src/ImageGallery.types.tsx
index 98e4b04..af8e573 100644
--- a/src/ImageGallery.types.tsx
+++ b/src/ImageGallery.types.tsx
@@ -14,25 +14,3 @@ export interface ImageDataType {
srcSet?: string;
mediaSizes?: string;
}
-
-export interface ImageGalleryPropsType {
- columnCount?: string | number;
- columnWidth?: string | number;
- customizeImageClickAction?: (
- imageData?: ImageDataType,
- index?: number,
- ) => void;
- enableDefaultLightbox?: boolean;
- fixedCaption?: boolean;
- gapSize?: number;
- imagesData: Array;
- lazy?: boolean;
- lazyFromIndex?: number;
- thumbnailBorder?: string;
-}
-
-export interface ImgSrcInfoType {
- mediaSizes: string | undefined;
- src: string;
- srcSet: string | undefined;
-}
diff --git a/src/hooks/useCarouselGesture.ts b/src/hooks/useCarouselGesture.ts
new file mode 100644
index 0000000..b639d75
--- /dev/null
+++ b/src/hooks/useCarouselGesture.ts
@@ -0,0 +1,181 @@
+import { useCallback, useEffect, useRef } from "react";
+
+interface DragResult {
+ action: "next" | "prev" | "cancel";
+}
+
+interface UseCarouselGestureProps {
+ trackRef: React.RefObject;
+ onDragEnd: (result: DragResult) => void;
+ canNavigatePrev: boolean;
+ canNavigateNext: boolean;
+}
+
+export function useCarouselGesture({
+ trackRef,
+ onDragEnd,
+ canNavigatePrev,
+ canNavigateNext,
+}: UseCarouselGestureProps) {
+ const isDragging = useRef(false);
+ const startX = useRef(0);
+ const startY = useRef(0);
+ const currentX = useRef(0);
+ const startTime = useRef(0);
+ const isHorizontalIntent = useRef(null);
+ const rafId = useRef(null);
+ const trackWidth = useRef(0);
+
+ const resetRefs = useCallback(() => {
+ isDragging.current = false;
+ isHorizontalIntent.current = null;
+ currentX.current = 0;
+ if (rafId.current !== null) {
+ cancelAnimationFrame(rafId.current);
+ rafId.current = null;
+ }
+ }, []);
+
+ const updateTransform = useCallback(
+ (x: number) => {
+ if (trackRef.current) {
+ trackRef.current.style.transform = `translate3d(${x}px, 0, 0)`;
+ }
+ },
+ [trackRef],
+ );
+
+ const trackGesture = useCallback(
+ (e: React.PointerEvent) => {
+ if (!e.isPrimary) return;
+
+ if (trackRef.current) {
+ trackRef.current.style.transition = "";
+ trackWidth.current = trackRef.current.getBoundingClientRect().width;
+ if (typeof (e.target as HTMLElement).setPointerCapture === "function") {
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ }
+ }
+
+ isDragging.current = true;
+ startX.current = e.clientX;
+ startY.current = e.clientY;
+ currentX.current = 0;
+ startTime.current = performance.now();
+ isHorizontalIntent.current = null;
+
+ if (rafId.current !== null) {
+ cancelAnimationFrame(rafId.current);
+ }
+ },
+ [trackRef],
+ );
+
+ const trackDragOffset = useCallback(
+ (e: React.PointerEvent) => {
+ if (!isDragging.current || !e.isPrimary) return;
+
+ const deltaX = e.clientX - startX.current;
+ const deltaY = e.clientY - startY.current;
+
+ if (isHorizontalIntent.current === null) {
+ if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
+ isHorizontalIntent.current = Math.abs(deltaX) > Math.abs(deltaY);
+ }
+ }
+
+ if (isHorizontalIntent.current === false) {
+ return;
+ }
+
+ if (isHorizontalIntent.current) {
+ e.preventDefault();
+
+ let newX = deltaX;
+
+ if (newX > 0 && !canNavigatePrev) {
+ newX = newX * 0.25;
+ } else if (newX < 0 && !canNavigateNext) {
+ newX = newX * 0.25;
+ }
+
+ currentX.current = newX;
+
+ if (rafId.current === null) {
+ rafId.current = requestAnimationFrame(() => {
+ updateTransform(currentX.current);
+ rafId.current = null;
+ });
+ }
+ }
+ },
+ [canNavigateNext, canNavigatePrev, updateTransform],
+ );
+
+ const evaluateAndCommitSwipe = useCallback(
+ (e: React.PointerEvent) => {
+ if (!isDragging.current || !e.isPrimary) return;
+
+ const duration = performance.now() - startTime.current;
+ const deltaX = currentX.current;
+ const absDeltaX = Math.abs(deltaX);
+ const velocity = absDeltaX / duration;
+
+ const threshold = trackWidth.current * 0.2;
+ const isFastFlick = velocity > 0.5 && duration < 250;
+ const passedThreshold = absDeltaX > threshold;
+
+ const wasHorizontal = isHorizontalIntent.current;
+
+ resetRefs();
+
+ if (trackRef.current) {
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
+ }
+
+ if (wasHorizontal) {
+ let action: "next" | "prev" | "cancel" = "cancel";
+ if ((passedThreshold || isFastFlick) && deltaX > 0 && canNavigatePrev) {
+ action = "prev";
+ } else if (
+ (passedThreshold || isFastFlick) &&
+ deltaX < 0 &&
+ canNavigateNext
+ ) {
+ action = "next";
+ }
+ onDragEnd({ action });
+ }
+ },
+ [canNavigateNext, canNavigatePrev, onDragEnd, resetRefs, trackRef],
+ );
+
+ const cancelGesture = useCallback(
+ (e: React.PointerEvent) => {
+ if (!isDragging.current || !e.isPrimary) return;
+
+ resetRefs();
+ if (trackRef.current) {
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
+ }
+ onDragEnd({ action: "cancel" });
+ },
+ [onDragEnd, resetRefs, trackRef],
+ );
+
+ useEffect(() => {
+ return () => {
+ if (rafId.current !== null) {
+ cancelAnimationFrame(rafId.current);
+ }
+ };
+ }, []);
+
+ return {
+ onPointerDown: trackGesture,
+ onPointerMove: trackDragOffset,
+ onPointerUp: evaluateAndCommitSwipe,
+ onPointerCancel: cancelGesture,
+ onLostPointerCapture: cancelGesture,
+ };
+}
diff --git a/src/lightbox/Caption.tsx b/src/lightbox/Caption.tsx
new file mode 100644
index 0000000..0f7fe12
--- /dev/null
+++ b/src/lightbox/Caption.tsx
@@ -0,0 +1,39 @@
+import { ImageDataType } from "../ImageGallery.types";
+
+interface CaptionProps {
+ fixedCaption?: boolean;
+ imageData: ImageDataType;
+ showControls: boolean;
+}
+
+export function Caption({
+ fixedCaption,
+ imageData,
+ showControls,
+}: CaptionProps) {
+ if (!imageData.caption) return null;
+
+ return (
+
+ {imageData.caption}
+ {imageData.cta?.href && imageData.cta?.text && (
+
+ )}
+
+ );
+}
diff --git a/src/lightbox/CarouselTrack.tsx b/src/lightbox/CarouselTrack.tsx
new file mode 100644
index 0000000..2f4e7ac
--- /dev/null
+++ b/src/lightbox/CarouselTrack.tsx
@@ -0,0 +1,266 @@
+import {
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { Caption } from "./Caption";
+import { useCarouselGesture } from "../hooks/useCarouselGesture";
+import { ImageDataType } from "../ImageGallery.types";
+
+export interface CarouselTrackRef {
+ goToSlide: (newSlideNumber: number) => void;
+ navigate: (direction: -1 | 1) => void;
+}
+
+interface CarouselTrackProps {
+ changeSlide: (newSlideNumber: number) => void;
+ fixedCaption?: boolean;
+ imagesData: Array;
+ lazy: boolean;
+ ref: React.Ref;
+ showControls: boolean;
+ showThumbnails: boolean;
+ slideNumber: number; // 1-indexed
+ toggleControls: () => void;
+}
+
+export function CarouselTrack({
+ changeSlide,
+ fixedCaption,
+ imagesData,
+ lazy,
+ ref,
+ showControls,
+ showThumbnails,
+ slideNumber,
+ toggleControls,
+}: CarouselTrackProps) {
+ const trackRef = useRef(null);
+ const isAnimating = useRef(false);
+ const targetSlide = useRef(slideNumber);
+ const [currentIndex, setCurrentIndex] = useState(slideNumber - 1);
+
+ const totalImages = imagesData.length;
+
+ // Determine the indices for prev and next slides, looping
+ const prevIndex = currentIndex === 0 ? totalImages - 1 : currentIndex - 1;
+ const nextIndex = currentIndex === totalImages - 1 ? 0 : currentIndex + 1;
+
+ function executeNavigation(direction: -1 | 1 | 0, fromX: number = 0) {
+ if (!trackRef.current) return;
+ isAnimating.current = true;
+
+ const track = trackRef.current;
+ const trackWidth = track.getBoundingClientRect().width;
+ const targetX =
+ direction === 1 ? -trackWidth : direction === -1 ? trackWidth : 0;
+
+ // If we are cancelling a drag, targetX is 0.
+ // Ensure we start from currentX if provided (for smooth cancel)
+ if (
+ fromX !== 0 &&
+ track.style.transform !== `translate3d(${fromX}px, 0px, 0px)`
+ ) {
+ track.style.transition = "";
+ track.style.transform = `translate3d(${fromX}px, 0, 0)`;
+ // Force layout
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ track.offsetHeight;
+ }
+
+ function finishTransition(direction: -1 | 1 | 0) {
+ if (direction === 0) {
+ // Just a cancel, reset animation state
+ isAnimating.current = false;
+ return;
+ }
+
+ let newSlideNumber = targetSlide.current;
+ if (direction === 1) {
+ newSlideNumber += 1;
+ } else if (direction === -1) {
+ newSlideNumber -= 1;
+ }
+
+ if (newSlideNumber > totalImages) newSlideNumber = 1;
+ if (newSlideNumber < 1) newSlideNumber = totalImages;
+
+ targetSlide.current = newSlideNumber;
+ setCurrentIndex(newSlideNumber - 1);
+ // Updating the state will trigger the useLayoutEffect which resets the track
+ changeSlide(newSlideNumber);
+ }
+
+ // Check if reduced motion is enabled
+ const isReducedMotion =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ if (isReducedMotion) {
+ // No animation, just update logical state immediately
+ finishTransition(direction);
+ return;
+ }
+
+ // Enable transition
+ track.style.transition =
+ "transform var(--cs-lightbox-transition-duration, 300ms) var(--cs-lightbox-transition-easing, cubic-bezier(0.22, 1, 0.36, 1))";
+ track.style.transform = `translate3d(${targetX}px, 0, 0)`;
+
+ let transitionFired = false;
+
+ function onTransitionEnd(e: TransitionEvent) {
+ if (e.target === track && e.propertyName === "transform") {
+ transitionFired = true;
+ track.removeEventListener("transitionend", onTransitionEnd);
+ clearTimeout(fallbackTimeout);
+ finishTransition(direction);
+ }
+ }
+
+ track.addEventListener("transitionend", onTransitionEnd);
+
+ // Fallback for interrupted transitions
+ const fallbackTimeout = setTimeout(() => {
+ if (!transitionFired) {
+ track.removeEventListener("transitionend", onTransitionEnd);
+ finishTransition(direction);
+ }
+ }, 400);
+ }
+
+ function navigate(direction: -1 | 1) {
+ if (isAnimating.current || !trackRef.current || totalImages <= 1) return;
+ executeNavigation(direction);
+ }
+
+ function goToSlide(newSlideNumber: number) {
+ if (newSlideNumber === slideNumber || totalImages <= 1) return;
+
+ // Direct navigation: completely reset visual state and instantly jump
+ isAnimating.current = false;
+ targetSlide.current = newSlideNumber;
+ setCurrentIndex(newSlideNumber - 1);
+
+ if (trackRef.current) {
+ trackRef.current.style.transition = "";
+ trackRef.current.style.transform = "translate3d(0px, 0, 0)";
+ }
+
+ changeSlide(newSlideNumber);
+ }
+
+ function executeSwipeNavigation(result: {
+ action: "next" | "prev" | "cancel";
+ }) {
+ if (!trackRef.current || totalImages <= 1) return;
+
+ // Get current transform X from the track style to know where to animate from
+ const style = trackRef.current.style.transform;
+ const match = style.match(/translate3d\(([-\d.]+)px/);
+ const currentX = match ? parseFloat(match[1]) : 0;
+
+ if (result.action === "next") {
+ executeNavigation(1, currentX);
+ } else if (result.action === "prev") {
+ executeNavigation(-1, currentX);
+ } else {
+ executeNavigation(0, currentX); // cancel / snap back
+ }
+ }
+
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onPointerCancel,
+ onLostPointerCapture,
+ } = useCarouselGesture({
+ trackRef,
+ onDragEnd: executeSwipeNavigation,
+ canNavigatePrev: totalImages > 1, // Currently looping is always enabled if > 1 slide
+ canNavigateNext: totalImages > 1,
+ });
+
+ function renderSlide(index: number, position: "prev" | "curr" | "next") {
+ const imageData = imagesData[index];
+ if (!imageData) return null;
+
+ const imageLoading =
+ position === "curr" ? (lazy ? "lazy" : "eager") : "eager";
+
+ // Stable keys based on imageData.id
+ return (
+
+
+ {
+ e.stopPropagation();
+ toggleControls();
+ }}
+ />
+
+
+
+ );
+ }
+
+ useImperativeHandle(ref, () => ({
+ navigate,
+ goToSlide,
+ }));
+
+ // Keep targetSlide in sync with external changes (e.g. thumbnails)
+ useEffect(() => {
+ targetSlide.current = slideNumber;
+ setCurrentIndex(slideNumber - 1);
+ }, [slideNumber]);
+
+ // Reset track position instantly after a logical slide change
+ useLayoutEffect(() => {
+ if (trackRef.current) {
+ trackRef.current.style.transition = "";
+ trackRef.current.style.transform = "translate3d(0px, 0, 0)";
+ // Force layout recalculation
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ trackRef.current.offsetHeight;
+ isAnimating.current = false;
+ }
+ }, [slideNumber]);
+
+ return (
+
+
+ {totalImages > 1 && renderSlide(prevIndex, "prev")}
+ {totalImages > 0 && renderSlide(currentIndex, "curr")}
+ {totalImages > 1 && renderSlide(nextIndex, "next")}
+
+
+ );
+}
diff --git a/src/lightbox/Lightbox.tsx b/src/lightbox/Lightbox.tsx
new file mode 100644
index 0000000..7128230
--- /dev/null
+++ b/src/lightbox/Lightbox.tsx
@@ -0,0 +1,181 @@
+import { useRef, useEffect, useState } from "react";
+import { CarouselTrack, CarouselTrackRef } from "./CarouselTrack";
+import { Navigation } from "./Navigation";
+import { ThumbnailStrip } from "./ThumbnailStrip";
+import { Toolbar } from "./Toolbar";
+import { ImageDataType } from "../ImageGallery.types";
+
+interface LightboxProps {
+ exitLightbox: () => void;
+ fixedCaption?: boolean;
+ imagesData: Array;
+ initialSlideNumber: number;
+ isOpen: boolean;
+ lazy: boolean;
+ setShowThumbnails: (show: boolean) => void;
+ showThumbnails: boolean;
+ thumbnailBorder: string;
+}
+
+export function Lightbox({
+ exitLightbox,
+ fixedCaption,
+ imagesData,
+ initialSlideNumber,
+ isOpen,
+ lazy,
+ setShowThumbnails,
+ showThumbnails,
+ thumbnailBorder,
+}: LightboxProps) {
+ const [fullscreen, setFullscreen] = useState(false);
+ const [showModalControls, setShowModalControls] = useState(false);
+ const [slideNumber, setSlideNumber] = useState(initialSlideNumber);
+
+ const carouselRef = useRef(null);
+ const dialogRef = useRef(null);
+ const lightboxRef = useRef(null);
+
+ function switchFullScreen(on: boolean) {
+ if (on) {
+ lightboxRef.current?.requestFullscreen().catch((error) => {
+ alert(
+ `Error while attempting to switch into fullscreen mode: ${error.message}`,
+ );
+ });
+ } else {
+ document.exitFullscreen().catch((error) => console.error(error));
+ }
+ }
+
+ function exitFullScreenAndDialog() {
+ if (fullscreen) switchFullScreen(false);
+ dialogRef.current?.close();
+ exitLightbox();
+ }
+
+ function closeLightbox(e: React.MouseEvent) {
+ const target = e.target as HTMLElement;
+ const isBackdropClick =
+ target.tagName === "SECTION" ||
+ target.tagName === "ARTICLE" ||
+ target.tagName === "FIGURE" ||
+ target.classList.contains("cs-rigg-carousel-slide") ||
+ target.classList.contains("cs-rigg-carousel-track-container") ||
+ target.classList.contains("cs-rigg-carousel-track");
+
+ if (isBackdropClick) exitFullScreenAndDialog();
+ }
+
+ function handleKeyboardShortcut(e: React.KeyboardEvent) {
+ if (e.key === "ArrowLeft") {
+ carouselRef.current?.navigate(-1);
+ } else if (e.key === "ArrowRight") {
+ carouselRef.current?.navigate(1);
+ } else if (e.key === "f") {
+ switchFullScreen(!fullscreen);
+ } else if (e.key === "Escape") {
+ e.preventDefault(); // Prevent default dialog closing so we can run our cleanup
+ exitFullScreenAndDialog();
+ }
+ }
+
+ function goToClickedThumbnail(index: number) {
+ carouselRef.current?.goToSlide(index + 1);
+ }
+
+ // Sync initial slide
+ useEffect(() => {
+ setSlideNumber(initialSlideNumber);
+ }, [initialSlideNumber]);
+
+ // Handle open/close and scroll lock
+ useEffect(() => {
+ if (!isOpen) {
+ if (dialogRef.current?.open) dialogRef.current.close();
+ return;
+ }
+
+ if (dialogRef.current && !dialogRef.current.open)
+ dialogRef.current.showModal();
+
+ const html = document.documentElement;
+ const originalHtmlOverflow = html.style.overflow;
+
+ html.style.overflow = "hidden";
+ return () => {
+ html.style.overflow = originalHtmlOverflow;
+ };
+ }, [isOpen]);
+
+ // Keep track of fullscreen state
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function syncFullscreenState() {
+ setFullscreen(Boolean(document.fullscreenElement));
+ lightboxRef.current?.focus();
+ }
+
+ document.addEventListener("fullscreenchange", syncFullscreenState);
+ return () =>
+ document.removeEventListener("fullscreenchange", syncFullscreenState);
+ }, [isOpen]);
+
+ return (
+ {
+ e.preventDefault();
+ exitFullScreenAndDialog();
+ }}
+ >
+ setShowModalControls(true)}
+ onMouseLeave={() => setShowModalControls(false)}
+ ref={lightboxRef}
+ tabIndex={-1}
+ >
+ setShowThumbnails(!showThumbnails)}
+ totalImages={imagesData.length}
+ exitFullScreenAndDialog={exitFullScreenAndDialog}
+ />
+
+ setShowModalControls((prev) => !prev)}
+ ref={carouselRef}
+ />
+ carouselRef.current?.navigate(dir)}
+ showControls={showModalControls}
+ />
+
+
+
+
+ );
+}
diff --git a/src/lightbox/Navigation.tsx b/src/lightbox/Navigation.tsx
new file mode 100644
index 0000000..f80ff07
--- /dev/null
+++ b/src/lightbox/Navigation.tsx
@@ -0,0 +1,43 @@
+import { SvgElement } from "../helpers";
+
+interface NavigationProps {
+ changeSlide: (direction: -1 | 1) => void;
+ showControls: boolean;
+}
+
+export function Navigation({ changeSlide, showControls }: NavigationProps) {
+ return (
+ <>
+ changeSlide(-1)}
+ style={{ opacity: showControls ? 1 : 0 }}
+ title="Previous image"
+ type="button"
+ >
+ {SvgElement(
+ ,
+ )}
+
+ changeSlide(1)}
+ style={{ opacity: showControls ? 1 : 0 }}
+ title="Next image"
+ type="button"
+ >
+ {SvgElement(
+ ,
+ )}
+
+ >
+ );
+}
diff --git a/src/lightbox/ThumbnailStrip.tsx b/src/lightbox/ThumbnailStrip.tsx
new file mode 100644
index 0000000..105d9c7
--- /dev/null
+++ b/src/lightbox/ThumbnailStrip.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef } from "react";
+import { ImageDataType } from "../ImageGallery.types";
+
+interface ThumbnailStripProps {
+ goToClickedThumbnail: (index: number) => void;
+ imagesData: Array;
+ lazy: boolean;
+ showThumbnails: boolean;
+ slideNumber: number;
+ thumbnailBorder: string;
+}
+
+export function ThumbnailStrip({
+ goToClickedThumbnail,
+ imagesData,
+ lazy,
+ showThumbnails,
+ slideNumber,
+ thumbnailBorder,
+}: ThumbnailStripProps) {
+ const activeThumbImgRef = useRef(null);
+
+ useEffect(() => {
+ if (showThumbnails && activeThumbImgRef.current) {
+ activeThumbImgRef.current.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ inline: "center",
+ });
+ }
+ }, [slideNumber, showThumbnails]);
+
+ return (
+
+
+ {imagesData.map((imageData, index) => (
+
goToClickedThumbnail(index)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/src/lightbox/Toolbar.tsx b/src/lightbox/Toolbar.tsx
new file mode 100644
index 0000000..0b86612
--- /dev/null
+++ b/src/lightbox/Toolbar.tsx
@@ -0,0 +1,83 @@
+import { SvgElement } from "../helpers";
+
+interface ToolbarProps {
+ exitFullScreenAndDialog: () => void;
+ fullscreen: boolean;
+ showControls: boolean;
+ slideNumber: number;
+ toggleFullscreen: (on: boolean) => void;
+ toggleThumbnails: () => void;
+ totalImages: number;
+}
+
+export function Toolbar({
+ exitFullScreenAndDialog,
+ fullscreen,
+ showControls,
+ slideNumber,
+ toggleFullscreen,
+ toggleThumbnails,
+ totalImages,
+}: ToolbarProps) {
+ return (
+ <>
+
+ {`${slideNumber} / ${totalImages}`}
+
+
+
+ {SvgElement(
+ ,
+ )}
+
+ toggleFullscreen(true)}
+ >
+ {SvgElement(
+ ,
+ )}
+
+ toggleFullscreen(false)}
+ >
+ {SvgElement(
+ ,
+ )}
+
+
+ {SvgElement(
+ ,
+ )}
+
+
+ >
+ );
+}
diff --git a/src/style.css b/src/style.css
index c50c44c..0c78d0c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -33,6 +33,13 @@
.cs-rigg-dialog {
margin: auto;
+ border: none;
+ padding: 0;
+ background: transparent;
+}
+
+.cs-rigg-dialog::backdrop {
+ background: transparent;
}
.cs-rigg-modal-container {
@@ -42,42 +49,71 @@
left: 0;
width: 100vw;
height: 100vh;
- background-color: rgba(0, 0, 0, 0.73);
- -webkit-backdrop-filter: blur(5px);
- backdrop-filter: blur(5px);
+ background-color: rgba(18, 18, 18, 0.85);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ display: flex;
+ flex-direction: column;
}
.cs-rigg-modal-slide-number {
- z-index: 1;
+ z-index: 100;
position: absolute;
- left: 0;
- padding: 13px;
- background-color: rgba(35, 35, 35, 0.73);
+ left: 16px;
+ top: 16px;
+ padding: 8px 16px;
+ background-color: rgba(18, 18, 18, 0.55);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
color: #fff;
font-size: 0.93rem;
- transition: opacity 1s ease-in-out;
+ transition: opacity 0.3s ease-in-out;
user-select: none;
-webkit-user-select: none;
+ -webkit-backdrop-filter: blur(16px);
+ backdrop-filter: blur(16px);
}
.cs-rigg-modal-toolbar {
- z-index: 1;
+ z-index: 100;
display: flex;
position: absolute;
- right: 0;
+ right: 16px;
+ top: 16px;
align-items: center;
- background-color: rgba(35, 35, 35, 0.73);
- transition: opacity 1s ease-in-out;
+ background-color: rgba(18, 18, 18, 0.55);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+ transition: opacity 0.3s ease-in-out;
cursor: pointer;
+ padding: 4px;
+ -webkit-backdrop-filter: blur(16px);
+ backdrop-filter: blur(16px);
}
.cs-rigg-modal-toolbar-btn {
margin: 0;
border: none;
background: none;
- padding: 13px;
+ padding: 10px;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
color: #fff;
cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease;
+}
+
+.cs-rigg-modal-toolbar-btn:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.cs-rigg-modal-toolbar-btn:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: -2px;
}
.cs-rigg-modal-slide-show-section {
@@ -85,38 +121,167 @@
display: flex;
align-items: center;
justify-content: center;
- width: inherit;
- transition: height 0.7s linear;
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.cs-rigg-carousel-track-container {
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ touch-action: pan-y;
+}
+
+.cs-rigg-carousel-track {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ will-change: transform;
+}
+
+.cs-rigg-carousel-slide {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ user-select: none;
+}
+
+.cs-rigg-carousel-slide figure {
+ margin: 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+.cs-rigg-carousel-slide[data-position="prev"] {
+ left: -100%;
+}
+
+.cs-rigg-carousel-slide[data-position="curr"] {
+ left: 0;
+}
+
+.cs-rigg-carousel-slide[data-position="next"] {
+ left: 100%;
}
.cs-rigg-modal-slide-btn {
position: absolute;
+ z-index: 50;
border: none;
- margin-inline: 7px;
- padding: 10px 15px;
- background-color: rgba(35, 35, 35, 0.73);
+ margin-inline: 16px;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background-color: rgba(18, 18, 18, 0.55);
+ border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
- transition: opacity 1s ease-in-out;
+ transition:
+ opacity 0.3s ease-in-out,
+ background-color 0.2s ease,
+ transform 0.2s ease;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
+ -webkit-backdrop-filter: blur(16px);
+ backdrop-filter: blur(16px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.cs-rigg-modal-slide-btn:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ transform: scale(1.05);
+}
+
+.cs-rigg-modal-slide-btn:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: -2px;
+}
+
+.cs-rigg-nav-prev {
+ left: 0;
+}
+
+.cs-rigg-nav-next {
+ right: 0;
}
.cs-rigg-modal-image {
margin: auto;
max-width: 100vw;
- transition: height 0.7s linear;
+ max-height: 100vh;
+ object-fit: contain;
+ transition: max-height 0.4s cubic-bezier(0.22, 1, 0.36, 1);
+ user-select: none;
+ -webkit-user-drag: none;
}
.cs-rigg-modal-thumbnail-section {
- overflow: hidden;
+ overflow-x: auto;
+ overflow-y: hidden;
height: 20vh;
text-align: center;
+ transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1);
+ background-color: rgba(0, 0, 0, 0.3);
+ scrollbar-width: none;
+ /* Firefox */
+ -ms-overflow-style: none;
+ /* Internet Explorer 10+ */
+}
+
+.cs-rigg-modal-thumbnail-section::-webkit-scrollbar {
+ display: none;
+ /* Safari and Chrome */
}
.cs-rigg-modal-thumb-imgs-pod {
display: inline-flex;
- height: inherit;
- padding-block: 12px;
- column-gap: 7px;
+ height: 100%;
+ padding: 16px;
+ column-gap: 12px;
+ align-items: center;
+}
+
+.cs-rigg-modal-thumb-imgs-pod img {
+ height: 100%;
+ max-height: 100px;
+ object-fit: cover;
+ border-radius: 8px;
+ transition:
+ transform 0.2s ease,
+ opacity 0.2s ease;
+ opacity: 0.6;
+}
+
+.cs-rigg-modal-thumb-imgs-pod img[style*="border"] {
+ opacity: 1;
+ transform: scale(1.05);
+}
+
+.cs-rigg-modal-thumb-imgs-pod img:hover {
+ opacity: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cs-rigg-modal-slide-number,
+ .cs-rigg-modal-toolbar,
+ .cs-rigg-modal-slide-btn,
+ .cs-rigg-modal-thumbnail-section,
+ .cs-rigg-modal-image,
+ .cs-rigg-modal-thumb-imgs-pod img {
+ transition: none !important;
+ }
}