Skip to content

Commit

Permalink
feat: carousel base
Browse files Browse the repository at this point in the history
  • Loading branch information
Emmanuel-Develops committed Aug 28, 2024
1 parent 6604f36 commit 8885951
Show file tree
Hide file tree
Showing 9 changed files with 365 additions and 1 deletion.
58 changes: 58 additions & 0 deletions src/components/carousel/Carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from "react";
import { Meta } from "@storybook/react";

import { Carousel } from "../carousel";
import { ArrowLeft, ArrowRight } from "../../icons";

export default {
title: "Components/Carousel",
argTypes: {
colorMode: { control: { type: "radio" }, options: ["light", "dark"], defaultValue: "light" }
}
} as Meta;

const PlaceholderCarouselItem = () => {
return (
<div className={`w-[300px] rounded-md p-2 border border-gray-200 shadow-sm`}>
<h2 className="text-2xl font-bold mb-4 text-gray-600">
Lorem ipsum dolor sit amet
</h2>
<p className="text-sm text-gray-800">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dignissimos voluptatibus illum quae officia hic, provident aliquam? Quos nemo asperiores, consequuntur molestiae culpa rem ea corporis ratione voluptatibus pariatur tenetur perspiciatis.
</p>
</div>
)
}

export const CarouselSample = (args: any) => {
const { colorMode } = args;
const isDark = colorMode === "dark";

return (
<div className={isDark ? "dark" : ""}>
<Carousel config={{stepWidthInPercent: 40}}>
<Carousel.Container>
<Carousel.Item>
<PlaceholderCarouselItem />
</Carousel.Item>
<Carousel.Item>
<PlaceholderCarouselItem />
</Carousel.Item>
<Carousel.Item>
<PlaceholderCarouselItem />
</Carousel.Item>
<Carousel.Item>
<PlaceholderCarouselItem />
</Carousel.Item>
<Carousel.Item>
<PlaceholderCarouselItem />
</Carousel.Item>
</Carousel.Container>
<Carousel.Controls>
<Carousel.PreviousButton icon={<ArrowLeft />} />
<Carousel.NextButton icon={<ArrowRight />} />
</Carousel.Controls>
</Carousel>
</div>
);
};
146 changes: 146 additions & 0 deletions src/components/carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
'use client';

import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { CarouselConfig, DefaultCarouselConfig } from './defaults';
import { throttledDebounce } from '../../utils';
import { CarouselButtonProps, CarouselContainer, CarouselContainerProps, CarouselControlProps, CarouselControls, CarouselItem, CarouselItemProps, CarouselNextButton, CarouselPreviousButton } from './CarouselComponents';

export interface CarouselContextType {
containerRef: React.RefObject<HTMLDivElement>;
totalCarouselItems: number;
goToNextSlide: () => void;
goToPreviousSlide: () => void;
possibleDirection: {
canGoToNextSlide: boolean;
canGoToPreviousSlide: boolean;
};
}

const CarouselContext = React.createContext<CarouselContextType | null>(null)

export const useCarousel = () => {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a CarouselProvider')
}
return context
}

export interface CarouselProviderProps {
children: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement>;
config?: CarouselConfig;
}

const CarouselProvider: React.FC<CarouselProviderProps> = ({ children, containerRef, config = DefaultCarouselConfig }) => {
const {stepWidthInPercent} = config;

const [carouselWidth, setCarouselWidth] = React.useState(0);
const [scrollableWidth, setScrollableWidth] = React.useState(0);
const [scrollLeft, setScrollLeft] = React.useState(0);

const possibleDirection = useMemo(() => {
console.log("I ran update direction")
if (!containerRef.current) return { canGoToNextSlide: false, canGoToPreviousSlide: false };
const canGoToNextSlide = scrollLeft < scrollableWidth - carouselWidth;
const canGoToPreviousSlide = scrollLeft > 0;
return { canGoToNextSlide, canGoToPreviousSlide };
}, [containerRef, scrollableWidth, carouselWidth, scrollLeft]);

const handleScroll = throttledDebounce(() => {
if (!containerRef.current) return;
setScrollLeft(containerRef.current?.scrollLeft ?? 0);
}, 200);

// init update containerRef details on mount and resize
useLayoutEffect(() => {
if (!containerRef.current) return;

const updateSize = throttledDebounce(() => {
setCarouselWidth(containerRef.current?.clientWidth ?? 0);
setScrollableWidth(containerRef.current?.scrollWidth ?? 0);
setScrollLeft(containerRef.current?.scrollLeft ?? 0);
console.log("i updated size", "width", containerRef.current?.clientWidth, "scrollable", containerRef.current?.scrollWidth)
}, 200);

const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);

// Initial size update
updateSize();

return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, []);

// update scroll position on scroll
useLayoutEffect(() => {
if (!containerRef.current) return;

containerRef.current?.addEventListener('scroll', handleScroll);

return () => {
if (containerRef.current) {
containerRef.current.removeEventListener('scroll', handleScroll);
}
};
}, []);

const totalCarouselItems = useMemo(() => {
console.log(containerRef.current)
return containerRef.current?.children.length ?? 0
}, [containerRef])

const goToNextSlide = useCallback(() => {
if (!containerRef.current) return;
const stepWidth = containerRef.current.clientWidth * stepWidthInPercent / 100
const responsiveStepWidth = stepWidth < containerRef.current.children[0].clientWidth ? containerRef.current.clientWidth : stepWidth;
const scrollLeft = containerRef.current.scrollLeft + responsiveStepWidth;
containerRef.current.scrollTo({
left: scrollLeft,
behavior: 'smooth',
});
}, [containerRef, stepWidthInPercent]);

const goToPreviousSlide = useCallback(() => {
if (!containerRef.current) return;
const stepWidth = containerRef.current.clientWidth * stepWidthInPercent / 100
// const responsiveStepWidth = Math.max(containerRef.current.clientWidth, containerRef.current.clientWidth * stepWidthInPercent / 100) ;
const responsiveStepWidth = stepWidth < containerRef.current.children[0].clientWidth ? containerRef.current.clientWidth : stepWidth;
const scrollLeft = Math.max(0, containerRef.current.scrollLeft - responsiveStepWidth);
containerRef.current.scrollTo({
left: scrollLeft,
behavior: 'smooth',
});
}, [containerRef, stepWidthInPercent]);

return (
<CarouselContext.Provider value={{containerRef, totalCarouselItems, goToNextSlide, goToPreviousSlide, possibleDirection }}>
{children}
</CarouselContext.Provider>
)
}

export const Carousel: React.FC<Omit<CarouselProviderProps, 'containerRef'>> & {
Container: React.FC<CarouselContainerProps>;
Item: React.FC<CarouselItemProps>;
Controls: React.FC<CarouselControlProps>;
PreviousButton: React.FC<CarouselButtonProps>;
NextButton: React.FC<CarouselButtonProps>;
} = ({ children, config }) => {
const containerRef = useRef<HTMLDivElement>(null)
return (
<CarouselProvider containerRef={containerRef} config={config}>
{children}
</CarouselProvider>
)
}

Carousel.Container = CarouselContainer;
Carousel.Item = CarouselItem;
Carousel.Controls = CarouselControls;
Carousel.PreviousButton = CarouselPreviousButton;
Carousel.NextButton = CarouselNextButton;
87 changes: 87 additions & 0 deletions src/components/carousel/CarouselComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react'
import { CarouselContextType, useCarousel } from './Carousel';
import { ComponentStylePrimitiveProps } from '../../primitives/types';

export interface CarouselContainerProps extends ComponentStylePrimitiveProps<HTMLDivElement> {
children: React.ReactNode
}

export const CarouselContainer: React.FC<CarouselContainerProps> = ({ children, ...props }) => {
const { className, ...rest } = props
const { containerRef } = useCarousel();
return (
<div ref={containerRef} className={`max-w-full h-full flex overflow-scroll gap-2 no-scrollbar ${className}`} {...rest}>
{children}
</div>
)
}

export interface CarouselItemProps extends CarouselContainerProps { }

export const CarouselItem: React.FC<CarouselItemProps> = ({ children, ...props }) => {
const { className, ...rest } = props
return (
<div className={`flex-shrink-0 relative ${className}`} {...rest}>
{children}
</div>
)
}

export interface CarouselControlProps extends ComponentStylePrimitiveProps<HTMLDivElement> {
children: React.ReactNode
}

export const CarouselControls: React.FC<CarouselControlProps> = ({ children, className, ...props }) => {
return (
<div className={`flex items-center gap-2 md:gap-4 w-fit mx-auto pt-4 ${className}`} {...props}>
{children}
</div>
)
}
export interface CarouselButtonProps extends Omit<ComponentStylePrimitiveProps<HTMLButtonElement>, 'children'> {
children?: React.ReactNode | ((goToPreviousSlide: () => void, possibleDirection: CarouselContextType['possibleDirection']) => React.ReactNode);
icon: React.ReactNode;
}

export const CarouselPreviousButton: React.FC<CarouselButtonProps> = ({ children, ...props }) => {
const { goToPreviousSlide, possibleDirection } = useCarousel();

if (children) {
if (typeof children === 'function') {
return <>{children(goToPreviousSlide, possibleDirection)}</>;
} else {
console.warn('CarouselPreviousButton: Children prop is not a function (opts out of navigation logic). Rendering children as-is.');
return <>{children}</>;
}
}

const { icon, className, ...rest } = props

return (
<button onClick={goToPreviousSlide} disabled={!possibleDirection.canGoToPreviousSlide} className={`w-10 h-10 flex items-center justify-center rounded-full border border-gray-600 dark:border-gray-300 p-2 text-gray-600 dark:text-gray-300 transition-colors hover:bg-gray-100 dark:hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent ${className}`} {...rest}>
{icon}
</button>
);
};

export const CarouselNextButton: React.FC<CarouselButtonProps> = ({ children, ...props }) => {
const { goToNextSlide, possibleDirection } = useCarousel();

if (children) {
if (typeof children === 'function') {
return <>{children(goToNextSlide, possibleDirection)}</>;
} else {
console.warn('CarouselNextButton: Children prop is not a function (opts out of navigation logic). Rendering children as-is.');
return <>{children}</>;
}
}

const { icon, className, ...rest } = props

return (
<button onClick={goToNextSlide} disabled={!possibleDirection.canGoToNextSlide} className={`w-10 h-10 flex items-center justify-center rounded-full border border-gray-600 dark:border-gray-300 p-2 text-gray-600 dark:text-gray-300 transition-colors hover:bg-gray-100 dark:hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent ${className}`} {...rest}>
{icon}
</button>
);
};

9 changes: 9 additions & 0 deletions src/components/carousel/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type CarouselConfig = {
stepWidthInPercent: number;
// TODO: Add support for scrollSteps
// scrollSteps?: number;
};

export const DefaultCarouselConfig: CarouselConfig = {
stepWidthInPercent: 100,
};
1 change: 1 addition & 0 deletions src/components/carousel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Carousel, type CarouselContextType } from "./Carousel";
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components/button';
export * from './components/footer';
export * from './components/footer';
export * from './components/carousel';
4 changes: 4 additions & 0 deletions src/primitives/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ComponentStylePrimitiveProps<T>
extends React.HTMLAttributes<T> {
className?: string;
}
12 changes: 12 additions & 0 deletions src/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
@tailwind components;
@tailwind utilities;

@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
Expand Down
46 changes: 46 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;

return function(this: any, ...args: Parameters<T>) {
const context = this;

const later = () => {
timeout = null;
func.apply(context, args);
};

if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

export function throttledDebounce<T extends (...args: any[]) => void>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean = false;
let lastArgs: Parameters<T> | null = null;

return function(this: any, ...args: Parameters<T>) {
const context = this;

if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
if (lastArgs) {
func.apply(context, lastArgs);
lastArgs = null;
}
}, limit);
} else {
lastArgs = args;
}
};
}

0 comments on commit 8885951

Please sign in to comment.