Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: adjust collection carousel ui #505

Merged
merged 18 commits into from
Nov 27, 2023
75 changes: 74 additions & 1 deletion resources/js/Components/Carousel/Carousel.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -62,4 +72,67 @@ describe("Carousel", () => {

expect(screen.getByTestId("CarouselNavigationButtons__previous")).toBeInTheDocument();
});

it("should render carousel pagination", () => {
render(
<CarouselPagination
carouselInstance={{
...new Swiper(".test"),
slides: ["" as unknown as HTMLElement, " " as unknown as HTMLElement],
on: vi.fn(),
off: vi.fn(),
}}
autoplayDelay={40}
/>,
);

expect(screen.getAllByTestId("CarouselPagination__item")).toHaveLength(2);
});

it("should not render carousel pagination if carousel instance is not provided", () => {
render(<CarouselPagination />);

expect(screen.queryByTestId("CarouselPagination__item")).not.toBeInTheDocument();
});

it("should slide to element when clicking pagination link", async () => {
const slideToMock = vi.fn();
render(
<CarouselPagination
carouselInstance={{
...new Swiper(".test"),
slides: ["" as unknown as HTMLElement, " " as unknown as HTMLElement],
on: vi.fn(),
off: vi.fn(),
slideTo: slideToMock,
}}
autoplayDelay={40}
/>,
);

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(
<CarouselPagination
carouselInstance={{
...new Swiper(".test"),
slides: ["" as unknown as HTMLElement, " " as unknown as HTMLElement],
off: vi.fn(),
}}
autoplayDelay={40}
/>,
);

expect(screen.getAllByTestId("CarouselPagination__progress-bar")[0]).toHaveAttribute("style", "width: 50%;");
});
});
54 changes: 49 additions & 5 deletions resources/js/Components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -114,11 +116,15 @@ export const Carousel = ({
<Swiper
style={{ paddingRight: (horizontalOffset ?? 0) * 2 }}
className={cn(swiperClassName)}
modules={[Navigation, Grid, Pagination]}
modules={[Navigation, Grid, Pagination, Autoplay, Pagination]}
slidesPerView={slidesPerView}
spaceBetween={spaceBetween}
slidesOffsetBefore={horizontalOffset}
slidesOffsetAfter={(horizontalOffset ?? 0) * -1}
pagination={{
el: ".carousel-pagination",
clickable: true,
}}
navigation={{
nextEl: `.carousel-button-next-${carouselKey}`,
prevEl: `.carousel-button-previous-${carouselKey}`,
Expand All @@ -133,12 +139,17 @@ export const Carousel = ({
export const CarouselPreviousButton = ({
carouselKey = "1",
disabled = false,
className,
}: {
carouselKey?: string;
disabled?: boolean;
className?: string;
}): JSX.Element => (
<IconButton
className={`carousel-button-previous-${carouselKey} dark:border-theme-dark-700 dark:disabled:bg-theme-dark-900 dark:disabled:text-theme-dark-400`}
className={twMerge(
`carousel-button-previous-${carouselKey} dark:border-theme-dark-700 dark:disabled:bg-theme-dark-900 dark:disabled:text-theme-dark-400`,
className,
)}
data-testid="CarouselNavigationButtons__previous"
icon="ChevronLeftSmall"
iconSize="xs"
Expand All @@ -157,7 +168,7 @@ export const CarouselNextButton = ({
}): JSX.Element => (
<IconButton
data-testid="CarouselNavigationButtons__next"
className={cn(
className={twMerge(
`carousel-button-next-${carouselKey} dark:border-theme-dark-700 dark:disabled:bg-theme-dark-900 dark:disabled:text-theme-dark-400`,
className,
)}
Expand All @@ -166,3 +177,36 @@ export const CarouselNextButton = ({
disabled={disabled}
/>
);

export const CarouselPagination = ({
carouselInstance,
autoplayDelay = 5000,
}: {
carouselInstance?: SwiperClass;
autoplayDelay?: number;
}): JSX.Element => {
const { activeIndex, progress } = useCarouselAutoplay({ carouselInstance, autoplayDelay });

return (
<div className="flex items-stretch space-x-2">
{Array.from({ length: carouselInstance?.slides.length ?? 0 }, (_, index) => (
<div
data-testid="CarouselPagination__item"
className="relative z-10 h-1 flex-grow cursor-pointer overflow-hidden rounded-full bg-theme-hint-200 dark:bg-theme-dark-700"
key={index}
onClick={() => {
carouselInstance?.slideTo(index);
}}
>
<div
data-testid="CarouselPagination__progress-bar"
className={cn(" absolute inset-y-0 left-0 bg-theme-hint-600 ", {
"transition-width duration-200 ease-linear": index === activeIndex,
})}
style={{ width: index === activeIndex ? `${progress}%` : 0 }}
/>
</div>
))}
</div>
);
};
137 changes: 137 additions & 0 deletions resources/js/Components/Carousel/Hooks/useCarouselAutoplay.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading