Skip to content

Commit

Permalink
feat: adding forwardRef in Carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
gnios committed Jul 15, 2024
1 parent 3217f88 commit 09a52ca
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 135 deletions.
33 changes: 32 additions & 1 deletion packages/ui/src/components/Carousel/Carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Meta, StoryFn } from "@storybook/react";
import type { CarouselProps } from "./Carousel";
import { useRef } from "react";
import { CarouselProps, CarouselRef } from "./Carousel";
import { Carousel } from "./Carousel";





export default {
title: "Components/Carousel",
component: Carousel,
Expand Down Expand Up @@ -45,3 +50,29 @@ WithNoIndicators.storyName = "With no indicators";
WithNoIndicators.args = {
indicators: false,
};

const ControlledTemplate: StoryFn<CarouselProps> = () => {
const carouselRef = useRef<CarouselRef>(null);

return (
<>
<div className="h-56 sm:h-64 xl:h-80 2xl:h-96">
<Carousel ref={carouselRef} slide={false}>
<img src="https://flowbite.com/docs/images/carousel/carousel-1.svg" alt="..." />
<img src="https://flowbite.com/docs/images/carousel/carousel-2.svg" alt="..." />
<img src="https://flowbite.com/docs/images/carousel/carousel-3.svg" alt="..." />
<img src="https://flowbite.com/docs/images/carousel/carousel-4.svg" alt="..." />
<img src="https://flowbite.com/docs/images/carousel/carousel-5.svg" alt="..." />
</Carousel>
</div>
<div style={{ marginTop: "20px", display: "flex", justifyContent: "center", gap: "10px" }}>
<button onClick={() => carouselRef.current?.prevSlide()}>Previous</button>
<button onClick={() => carouselRef.current?.nextSlide()}>Next</button>
<button onClick={() => carouselRef.current?.goToSlide(2)}>Go to Slide 3</button>
</div>
</>
);
};

export const ControlledCarousel = ControlledTemplate.bind({});
ControlledCarousel.args = {};
300 changes: 166 additions & 134 deletions packages/ui/src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
"use client";

import type { ComponentProps, FC, ReactElement, ReactNode } from "react";
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Children,
cloneElement,
ComponentProps,
FC,
forwardRef,
ReactElement,
ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { HiOutlineChevronLeft, HiOutlineChevronRight } from "react-icons/hi";
import { twMerge } from "tailwind-merge";
import ScrollContainer from "../../helpers/drag-scroll";
Expand Down Expand Up @@ -62,152 +75,171 @@ export interface DefaultLeftRightControlProps extends ComponentProps<"div"> {
theme?: DeepPartial<FlowbiteCarouselTheme>;
}

export const Carousel: FC<CarouselProps> = ({
children,
indicators = true,
leftControl,
rightControl,
slide = true,
draggable = true,
slideInterval,
className,
theme: customTheme = {},
onSlideChange = null,
pauseOnHover = false,
...props
}) => {
const theme = mergeDeep(getTheme().carousel, customTheme);
export interface CarouselRef {
nextSlide: () => void;
prevSlide: () => void;
goToSlide: (item: number) => void;
currentSlide: number;
}

const isDeviceMobile = isClient() && navigator.userAgent.indexOf("IEMobile") !== -1;
const carouselContainer = useRef<HTMLDivElement>(null);
const [activeItem, setActiveItem] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);

const didMountRef = useRef(false);

const items = useMemo(
() =>
Children.map(children as ReactElement[], (child: ReactElement) =>
cloneElement(child, {
className: twMerge(theme.item.base, child.props.className),
}),
),
[children, theme.item.base],
);
export const Carousel = forwardRef<HTMLDivElement | CarouselRef, CarouselProps>(
(
{
children,
indicators = true,
leftControl,
rightControl,
slide = true,
draggable = true,
slideInterval,
className,
theme: customTheme = {},
onSlideChange = null,
pauseOnHover = false,
...props
},
ref,
) => {
const theme = mergeDeep(getTheme().carousel, customTheme);

const isDeviceMobile = isClient() && navigator.userAgent.indexOf("IEMobile") !== -1;
const carouselContainer = useRef<HTMLDivElement>(null);
const [activeItem, setActiveItem] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);

const didMountRef = useRef(false);

const navigateTo = useCallback(
(item: number) => () => {
if (!items) return;
item = (item + items.length) % items.length;
if (carouselContainer.current) {
carouselContainer.current.scrollLeft = carouselContainer.current.clientWidth * item;
const items = useMemo(
() =>
Children.map(children as ReactElement[], (child: ReactElement) =>
cloneElement(child, {
className: twMerge(theme.item.base, child.props.className),
}),
),
[children, theme.item.base],
);

const navigateTo = useCallback(
(item: number) => () => {
if (!items) return;
item = (item + items.length) % items.length;
if (carouselContainer.current) {
carouselContainer.current.scrollLeft = carouselContainer.current.clientWidth * item;
}
setActiveItem(item);
},
[items],
);

useEffect(() => {
if (carouselContainer.current && !isDragging && carouselContainer.current.scrollLeft !== 0) {
setActiveItem(Math.round(carouselContainer.current.scrollLeft / carouselContainer.current.clientWidth));
}
setActiveItem(item);
},
[items],
);
}, [isDragging]);

useEffect(() => {
if (carouselContainer.current && !isDragging && carouselContainer.current.scrollLeft !== 0) {
setActiveItem(Math.round(carouselContainer.current.scrollLeft / carouselContainer.current.clientWidth));
}
}, [isDragging]);
useEffect(() => {
if (slide && !(pauseOnHover && isHovering)) {
const intervalId = setInterval(() => !isDragging && navigateTo(activeItem + 1)(), slideInterval ?? 3000);

useEffect(() => {
if (slide && !(pauseOnHover && isHovering)) {
const intervalId = setInterval(() => !isDragging && navigateTo(activeItem + 1)(), slideInterval ?? 3000);
return () => clearInterval(intervalId);
}
}, [activeItem, isDragging, navigateTo, slide, slideInterval, pauseOnHover, isHovering]);

return () => clearInterval(intervalId);
}
}, [activeItem, isDragging, navigateTo, slide, slideInterval, pauseOnHover, isHovering]);
useEffect(() => {
if (didMountRef.current) {
onSlideChange && onSlideChange(activeItem);
} else {
didMountRef.current = true;
}
}, [onSlideChange, activeItem]);

useEffect(() => {
if (didMountRef.current) {
onSlideChange && onSlideChange(activeItem);
} else {
didMountRef.current = true;
}
}, [onSlideChange, activeItem]);
useImperativeHandle(ref, () => ({
nextSlide: () => navigateTo(activeItem + 1)(),
prevSlide: () => navigateTo(activeItem - 1)(),
goToSlide: (item: number) => navigateTo(item)(),
currentSlide: activeItem,
}));

const handleDragging = (dragging: boolean) => () => setIsDragging(dragging);
const handleDragging = (dragging: boolean) => () => setIsDragging(dragging);

const setHoveringTrue = useCallback(() => setIsHovering(true), [setIsHovering]);
const setHoveringFalse = useCallback(() => setIsHovering(false), [setIsHovering]);
const setHoveringTrue = useCallback(() => setIsHovering(true), [setIsHovering]);
const setHoveringFalse = useCallback(() => setIsHovering(false), [setIsHovering]);

return (
<div
className={twMerge(theme.root.base, className)}
data-testid="carousel"
onMouseEnter={setHoveringTrue}
onMouseLeave={setHoveringFalse}
onTouchStart={setHoveringTrue}
onTouchEnd={setHoveringFalse}
{...props}
>
<ScrollContainer
className={twMerge(theme.scrollContainer.base, (isDeviceMobile || !isDragging) && theme.scrollContainer.snap)}
draggingClassName="cursor-grab"
innerRef={carouselContainer}
onEndScroll={handleDragging(false)}
onStartScroll={handleDragging(draggable)}
vertical={false}
horizontal={draggable}
return (
<div
className={twMerge(theme.root.base, className)}
data-testid="carousel"
onMouseEnter={setHoveringTrue}
onMouseLeave={setHoveringFalse}
onTouchStart={setHoveringTrue}
onTouchEnd={setHoveringFalse}
{...props}
>
{items?.map((item, index) => (
<div
key={index}
className={theme.item.wrapper[draggable ? "on" : "off"]}
data-active={activeItem === index}
data-testid="carousel-item"
>
{item}
</div>
))}
</ScrollContainer>
{indicators && (
<div className={theme.indicators.wrapper}>
{items?.map((_, index) => (
<button
<ScrollContainer
className={twMerge(theme.scrollContainer.base, (isDeviceMobile || !isDragging) && theme.scrollContainer.snap)}
draggingClassName="cursor-grab"
innerRef={carouselContainer}
onEndScroll={handleDragging(false)}
onStartScroll={handleDragging(draggable)}
vertical={false}
horizontal={draggable}
>
{items?.map((item, index) => (
<div
key={index}
className={twMerge(theme.indicators.base, theme.indicators.active[index === activeItem ? "on" : "off"])}
onClick={navigateTo(index)}
data-testid="carousel-indicator"
aria-label={`Slide ${index + 1}`}
/>
))}
</div>
)}

{items && (
<>
<div className={theme.root.leftControl}>
<button
className="group"
data-testid="carousel-left-control"
onClick={navigateTo(activeItem - 1)}
type="button"
aria-label="Previous slide"
>
{leftControl ? leftControl : <DefaultLeftControl theme={customTheme} />}
</button>
</div>
<div className={theme.root.rightControl}>
<button
className="group"
data-testid="carousel-right-control"
onClick={navigateTo(activeItem + 1)}
type="button"
aria-label="Next slide"
className={theme.item.wrapper[draggable ? "on" : "off"]}
data-active={activeItem === index}
data-testid="carousel-item"
>
{rightControl ? rightControl : <DefaultRightControl theme={customTheme} />}
</button>
{item}
</div>
))}
</ScrollContainer>
{indicators && (
<div className={theme.indicators.wrapper}>
{items?.map((_, index) => (
<button
key={index}
className={twMerge(theme.indicators.base, theme.indicators.active[index === activeItem ? "on" : "off"])}
onClick={navigateTo(index)}
data-testid="carousel-indicator"
aria-label={`Slide ${index + 1}`}
/>
))}
</div>
</>
)}
</div>
);
};
)}

{items && (
<>
<div className={theme.root.leftControl}>
<button
className="group"
data-testid="carousel-left-control"
onClick={navigateTo(activeItem - 1)}
type="button"
aria-label="Previous slide"
>
{leftControl ? leftControl : <DefaultLeftControl theme={customTheme} />}
</button>
</div>
<div className={theme.root.rightControl}>
<button
className="group"
data-testid="carousel-right-control"
onClick={navigateTo(activeItem + 1)}
type="button"
aria-label="Next slide"
>
{rightControl ? rightControl : <DefaultRightControl theme={customTheme} />}
</button>
</div>
</>
)}
</div>
);
},
);

const DefaultLeftControl: FC<DefaultLeftRightControlProps> = ({ theme: customTheme = {} }) => {
const theme = mergeDeep(getTheme().carousel, customTheme);
Expand Down

0 comments on commit 09a52ca

Please sign in to comment.