diff --git a/resources/js/Components/Carousel/Carousel.test.tsx b/resources/js/Components/Carousel/Carousel.test.tsx index 1b5f43ae5..5a9682a0e 100644 --- a/resources/js/Components/Carousel/Carousel.test.tsx +++ b/resources/js/Components/Carousel/Carousel.test.tsx @@ -1,5 +1,15 @@ +import userEvent from "@testing-library/user-event"; import React from "react"; -import { Carousel, CarouselControls, CarouselItem, CarouselNextButton, CarouselPreviousButton } from "./Carousel"; +import Swiper from "swiper"; +import { + Carousel, + CarouselControls, + CarouselItem, + CarouselNextButton, + CarouselPagination, + CarouselPreviousButton, +} from "./Carousel"; +import * as useCarouselAutoplayMock from "./Hooks/useCarouselAutoplay"; import { render, screen } from "@/Tests/testing-library"; describe("Carousel", () => { @@ -62,4 +72,67 @@ describe("Carousel", () => { expect(screen.getByTestId("CarouselNavigationButtons__previous")).toBeInTheDocument(); }); + + it("should render carousel pagination", () => { + render( + , + ); + + expect(screen.getAllByTestId("CarouselPagination__item")).toHaveLength(2); + }); + + it("should not render carousel pagination if carousel instance is not provided", () => { + render(); + + expect(screen.queryByTestId("CarouselPagination__item")).not.toBeInTheDocument(); + }); + + it("should slide to element when clicking pagination link", async () => { + const slideToMock = vi.fn(); + render( + , + ); + + expect(screen.getAllByTestId("CarouselPagination__item")).toHaveLength(2); + + await userEvent.click(screen.getAllByTestId("CarouselPagination__item")[0]); + expect(slideToMock).toHaveBeenCalled(); + }); + + it("should render pagination with progress bar", () => { + vi.spyOn(useCarouselAutoplayMock, "useCarouselAutoplay").mockImplementation(() => ({ + activeIndex: 0, + progress: 50, + })); + + render( + , + ); + + expect(screen.getAllByTestId("CarouselPagination__progress-bar")[0]).toHaveAttribute("style", "width: 50%;"); + }); }); diff --git a/resources/js/Components/Carousel/Carousel.tsx b/resources/js/Components/Carousel/Carousel.tsx index 10ae92f0f..247b121d8 100644 --- a/resources/js/Components/Carousel/Carousel.tsx +++ b/resources/js/Components/Carousel/Carousel.tsx @@ -2,9 +2,11 @@ import cn from "classnames"; import React, { type ComponentProps } from "react"; import { useTranslation } from "react-i18next"; -import { Grid, Navigation, Pagination } from "swiper"; +import { Autoplay, Grid, Navigation, Pagination } from "swiper"; import { Swiper } from "swiper/react"; -import { type GridOptions } from "swiper/types"; +import { type GridOptions, type Swiper as SwiperClass } from "swiper/types"; +import { twMerge } from "tailwind-merge"; +import { useCarouselAutoplay } from "./Hooks/useCarouselAutoplay"; import { IconButton } from "@/Components/Buttons"; import { ButtonLink } from "@/Components/Buttons/ButtonLink"; import { Heading } from "@/Components/Heading"; @@ -114,11 +116,15 @@ export const Carousel = ({ ( ( ); + +export const CarouselPagination = ({ + carouselInstance, + autoplayDelay = 5000, +}: { + carouselInstance?: SwiperClass; + autoplayDelay?: number; +}): JSX.Element => { + const { activeIndex, progress } = useCarouselAutoplay({ carouselInstance, autoplayDelay }); + + return ( +
+ {Array.from({ length: carouselInstance?.slides.length ?? 0 }, (_, index) => ( +
{ + carouselInstance?.slideTo(index); + }} + > +
+
+ ))} +
+ ); +}; diff --git a/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.test.ts b/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.test.ts new file mode 100644 index 000000000..000c96b42 --- /dev/null +++ b/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.test.ts @@ -0,0 +1,137 @@ +import { act, renderHook } from "@testing-library/react"; + +import Swiper from "swiper"; +import { type Swiper as SwiperClass } from "swiper/types"; +import { useCarouselAutoplay } from "./useCarouselAutoplay"; + +describe("useCarouselAutoplay", () => { + it("should stay idle if carousel instance is not provided", () => { + const { result } = renderHook(() => useCarouselAutoplay({ carouselInstance: undefined })); + + expect(result.current.activeIndex).toBe(0); + expect(result.current.progress).toBe(0); + }); + + it("should provide progress and active index", () => { + vi.useFakeTimers(); + + const carouselInstance = new Swiper(".test"); + const starEventMock = vi.fn(); + + const { result, rerender } = renderHook(() => + useCarouselAutoplay({ + carouselInstance: { + ...carouselInstance, + slides: ["" as unknown as HTMLElement, " " as unknown as HTMLElement], + on: (eventName: string, handler: (swiper: SwiperClass) => void) => { + handler(carouselInstance); + }, + off: vi.fn(), + autoplay: { + ...carouselInstance.autoplay, + paused: false, + running: true, + start: starEventMock, + }, + }, + autoplayDelay: 1000, + }), + ); + + expect(result.current.progress).toBe(0); + expect(result.current.activeIndex).toBe(0); + + expect(starEventMock).toHaveBeenCalled(); + + rerender(() => + useCarouselAutoplay({ + carouselInstance: undefined, + autoplayDelay: 1000, + }), + ); + + expect(result.current.progress).toBe(0); + expect(result.current.activeIndex).toBe(0); + }); + + it("should not update progress if slider is paused or not running", () => { + vi.useFakeTimers(); + + const carouselInstance = new Swiper(".test"); + const starEventMock = vi.fn(); + const slides = ["" as unknown as HTMLElement, " " as unknown as HTMLElement]; + + const { result } = renderHook(() => + useCarouselAutoplay({ + carouselInstance: { + ...carouselInstance, + slides, + on: (eventName: string, handler: (swiper: SwiperClass) => void) => { + handler({ + ...carouselInstance, + slides, + }); + }, + off: vi.fn(), + autoplay: { + ...carouselInstance.autoplay, + paused: true, + running: false, + start: starEventMock, + }, + }, + autoplayDelay: 100, + }), + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(starEventMock).toHaveBeenCalled(); + + expect(result.current.activeIndex).toBe(0); + expect(result.current.progress).toBe(0); + }); + + it("should move to last slide", () => { + vi.useFakeTimers(); + + const carouselInstance = new Swiper(".test"); + const starEventMock = vi.fn(); + const slides = ["" as unknown as HTMLElement, " " as unknown as HTMLElement]; + + const { result } = renderHook(() => + useCarouselAutoplay({ + carouselInstance: { + ...carouselInstance, + slides, + on: (eventName: string, handler: (swiper: SwiperClass) => void) => { + handler({ + ...carouselInstance, + activeIndex: -1, + slides, + }); + }, + off: vi.fn(), + autoplay: { + ...carouselInstance.autoplay, + paused: false, + running: true, + start: starEventMock, + }, + }, + autoplayDelay: 100, + }), + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(starEventMock).toHaveBeenCalled(); + + expect(result.current.activeIndex).toBe(2); + expect(result.current.progress).toBe(0); + }); +}); diff --git a/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.ts b/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.ts new file mode 100644 index 000000000..81704479e --- /dev/null +++ b/resources/js/Components/Carousel/Hooks/useCarouselAutoplay.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from "react"; + +import { type Swiper as SwiperClass } from "swiper/types"; +import { isTruthy } from "@/Utils/is-truthy"; + +/** + * Provides autoplay state handling. + * Returns progress until next slide change in percentage (based on autoPlayDelay), + * and the active slides index. + * + */ +export const useCarouselAutoplay = ({ + carouselInstance, + autoplayDelay = 5000, +}: { + carouselInstance?: SwiperClass; + autoplayDelay?: number; +}): { + progress: number; + activeIndex: number; +} => { + const [progress, setProgress] = useState(0); + const [activeIndex, setActiveIndex] = useState(0); + + /** + * Handle slide change event & ensure autoplay is always on, + * and update state accordingly + * + * @param {SwiperClass} swiper + * @returns {void} + */ + const handleSlideChange = useCallback( + (carousel: SwiperClass) => { + const start = (): boolean | undefined => carouselInstance?.autoplay.start(); + + /** + * Update active slide index state. + * activeIndex comes as -1 on the last slide. Corrects it to get the index of the last item. + * + * @param {SwiperClass} swiper + * @returns {void} + */ + const setActiveSlide = (swiper: SwiperClass): void => { + const index = swiper.activeIndex < 0 ? swiper.slides.length : swiper.activeIndex; + setActiveIndex(index); + setProgress(0); + }; + + /** + * Ensure autoplay is always on, as carousel can stop when + * manually changing slides (either by clicking on next/previous links or pagination links) + */ + carousel.on("autoplayStop", start); + + carousel.on("slideChange", setActiveSlide); + + return { + cleanup: () => { + carousel.off("autoplayStop", start); + carousel.off("slideChange", setActiveSlide); + setActiveIndex(0); + setProgress(0); + }, + }; + }, + [carouselInstance], + ); + + /** + * Update progress in percentage until next slide change. + * + * Although swiper emits `autoplayTimeLeft` with the percentage, + * it's not resetting the timer when a manual slide change happens + * (e.g when clicking next/previous arrows or pagination links). + * + * @param {SwiperClass} swiper + * @returns {void} + */ + const updateProgress = useCallback( + (carousel: SwiperClass): { interval: NodeJS.Timeout } => { + const progressUpdateInterval = 200; + + const interval = setInterval(() => { + if (carousel.autoplay.paused || !carousel.autoplay.running) { + return; + } + + const progressPercentageStep = (100 / autoplayDelay) * progressUpdateInterval; + setProgress((currentProgress) => Math.round(currentProgress + progressPercentageStep)); + }, progressUpdateInterval); + + return { interval }; + }, + [carouselInstance], + ); + + /** + * Start listening to slide change events, + * and calculate & update active slide's progress. + */ + useEffect(() => { + if (!isTruthy(carouselInstance)) { + return; + } + + const { cleanup } = handleSlideChange(carouselInstance); + const { interval } = updateProgress(carouselInstance); + + return () => { + // Clear all listeners and reset to defaults. + cleanup(); + clearInterval(interval); + }; + }, [carouselInstance, handleSlideChange, updateProgress]); + + return { + progress, + activeIndex, + }; +}; diff --git a/resources/js/Pages/Collections/Components/CollectionNft/CollectionNft.tsx b/resources/js/Pages/Collections/Components/CollectionNft/CollectionNft.tsx index 5a8ffb76b..625fddc61 100644 --- a/resources/js/Pages/Collections/Components/CollectionNft/CollectionNft.tsx +++ b/resources/js/Pages/Collections/Components/CollectionNft/CollectionNft.tsx @@ -9,10 +9,10 @@ import { isTruthy } from "@/Utils/is-truthy"; export const CollectionNft = ({ nft, - classNames, + className, }: { nft: App.Data.Gallery.GalleryNftData; - classNames?: string; + className?: string; }): JSX.Element => { const { t } = useTranslation(); @@ -30,7 +30,7 @@ export const CollectionNft = ({ })} className={cn( "transition-default group cursor-pointer rounded-xl border border-theme-secondary-300 p-2 ring-theme-primary-100 hover:ring-2 dark:border-theme-dark-700 dark:ring-theme-dark-700", - classNames, + className, )} > diff --git a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionNfts.tsx b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionNfts.tsx index ae0e6f5e7..585c9b5c1 100644 --- a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionNfts.tsx +++ b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionNfts.tsx @@ -1,32 +1,28 @@ -import cn from "classnames"; import React from "react"; +import { twMerge } from "tailwind-merge"; import { CollectionNft } from "@/Pages/Collections/Components/CollectionNft"; export const FeaturedCollectionNfts = ({ nfts }: { nfts: App.Data.Gallery.GalleryNftData[] }): JSX.Element => { - const defaultNftCardStyles = - "bg-white dark:bg-theme-dark-900 grid sm:w-full sm:h-full min-w-full lg:min-w-fit lg:w-52 lg:h-fit"; + const defaultClassName = "w-72 md:w-56 bg-white dark:bg-theme-dark-900 md-lg:w-52"; return ( -
1, - })} - > +
+ {nfts.length > 1 && ( )} + {nfts.length > 2 && (
diff --git a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsCarousel.tsx b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsCarousel.tsx index 0182094f6..469fbf2ce 100644 --- a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsCarousel.tsx +++ b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsCarousel.tsx @@ -1,28 +1,69 @@ -import React from "react"; -import { Autoplay, Navigation, Pagination } from "swiper"; +import React, { useState } from "react"; +import type Swiper from "swiper"; import { FeaturedCollectionsItem } from "./FeaturedCollectionsItem"; -import { Carousel, CarouselItem } from "@/Components/Carousel"; +import { + Carousel, + CarouselItem, + CarouselNextButton, + CarouselPagination, + CarouselPreviousButton, +} from "@/Components/Carousel"; export const FeaturedCollectionsCarousel = ({ featuredCollections, + autoplayDelay = 5000, }: { featuredCollections: App.Data.Collections.CollectionFeaturedData[]; -}): JSX.Element => ( - - {featuredCollections.map((collection, index) => ( - - - - ))} - -); + autoplayDelay?: number; +}): JSX.Element => { + const [carousel, setCarousel] = useState(); + + return ( +
+
+
+
+ +
+
+ + + {featuredCollections.map((collection, index) => ( + + + + ))} + + +
+
+ +
+
+
+ +
+ +
+
+ ); +}; diff --git a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsItem.tsx b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsItem.tsx index 672ab06a8..424a79bdc 100644 --- a/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsItem.tsx +++ b/resources/js/Pages/Collections/Components/FeaturedCollections/FeaturedCollectionsItem.tsx @@ -21,7 +21,7 @@ const FeaturedCollectionInfo = ({ data }: { data: App.Data.Collections.Collectio const { t } = useTranslation(); return ( -
+
@@ -46,7 +46,7 @@ const FeaturedCollectionInfo = ({ data }: { data: App.Data.Collections.Collectio
-
+
{truncateDescription(data.description)}
@@ -58,6 +58,7 @@ const FeaturedCollectionInfo = ({ data }: { data: App.Data.Collections.Collectio nftsCount={data.nftsCount} volume={data.volume} /> + (
-
+