Skip to content

Commit 8885951

Browse files
feat: carousel base
1 parent 6604f36 commit 8885951

File tree

9 files changed

+365
-1
lines changed

9 files changed

+365
-1
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from "react";
2+
import { Meta } from "@storybook/react";
3+
4+
import { Carousel } from "../carousel";
5+
import { ArrowLeft, ArrowRight } from "../../icons";
6+
7+
export default {
8+
title: "Components/Carousel",
9+
argTypes: {
10+
colorMode: { control: { type: "radio" }, options: ["light", "dark"], defaultValue: "light" }
11+
}
12+
} as Meta;
13+
14+
const PlaceholderCarouselItem = () => {
15+
return (
16+
<div className={`w-[300px] rounded-md p-2 border border-gray-200 shadow-sm`}>
17+
<h2 className="text-2xl font-bold mb-4 text-gray-600">
18+
Lorem ipsum dolor sit amet
19+
</h2>
20+
<p className="text-sm text-gray-800">
21+
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.
22+
</p>
23+
</div>
24+
)
25+
}
26+
27+
export const CarouselSample = (args: any) => {
28+
const { colorMode } = args;
29+
const isDark = colorMode === "dark";
30+
31+
return (
32+
<div className={isDark ? "dark" : ""}>
33+
<Carousel config={{stepWidthInPercent: 40}}>
34+
<Carousel.Container>
35+
<Carousel.Item>
36+
<PlaceholderCarouselItem />
37+
</Carousel.Item>
38+
<Carousel.Item>
39+
<PlaceholderCarouselItem />
40+
</Carousel.Item>
41+
<Carousel.Item>
42+
<PlaceholderCarouselItem />
43+
</Carousel.Item>
44+
<Carousel.Item>
45+
<PlaceholderCarouselItem />
46+
</Carousel.Item>
47+
<Carousel.Item>
48+
<PlaceholderCarouselItem />
49+
</Carousel.Item>
50+
</Carousel.Container>
51+
<Carousel.Controls>
52+
<Carousel.PreviousButton icon={<ArrowLeft />} />
53+
<Carousel.NextButton icon={<ArrowRight />} />
54+
</Carousel.Controls>
55+
</Carousel>
56+
</div>
57+
);
58+
};
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
'use client';
2+
3+
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
4+
import { CarouselConfig, DefaultCarouselConfig } from './defaults';
5+
import { throttledDebounce } from '../../utils';
6+
import { CarouselButtonProps, CarouselContainer, CarouselContainerProps, CarouselControlProps, CarouselControls, CarouselItem, CarouselItemProps, CarouselNextButton, CarouselPreviousButton } from './CarouselComponents';
7+
8+
export interface CarouselContextType {
9+
containerRef: React.RefObject<HTMLDivElement>;
10+
totalCarouselItems: number;
11+
goToNextSlide: () => void;
12+
goToPreviousSlide: () => void;
13+
possibleDirection: {
14+
canGoToNextSlide: boolean;
15+
canGoToPreviousSlide: boolean;
16+
};
17+
}
18+
19+
const CarouselContext = React.createContext<CarouselContextType | null>(null)
20+
21+
export const useCarousel = () => {
22+
const context = React.useContext(CarouselContext)
23+
if (!context) {
24+
throw new Error('useCarousel must be used within a CarouselProvider')
25+
}
26+
return context
27+
}
28+
29+
export interface CarouselProviderProps {
30+
children: React.ReactNode;
31+
containerRef: React.RefObject<HTMLDivElement>;
32+
config?: CarouselConfig;
33+
}
34+
35+
const CarouselProvider: React.FC<CarouselProviderProps> = ({ children, containerRef, config = DefaultCarouselConfig }) => {
36+
const {stepWidthInPercent} = config;
37+
38+
const [carouselWidth, setCarouselWidth] = React.useState(0);
39+
const [scrollableWidth, setScrollableWidth] = React.useState(0);
40+
const [scrollLeft, setScrollLeft] = React.useState(0);
41+
42+
const possibleDirection = useMemo(() => {
43+
console.log("I ran update direction")
44+
if (!containerRef.current) return { canGoToNextSlide: false, canGoToPreviousSlide: false };
45+
const canGoToNextSlide = scrollLeft < scrollableWidth - carouselWidth;
46+
const canGoToPreviousSlide = scrollLeft > 0;
47+
return { canGoToNextSlide, canGoToPreviousSlide };
48+
}, [containerRef, scrollableWidth, carouselWidth, scrollLeft]);
49+
50+
const handleScroll = throttledDebounce(() => {
51+
if (!containerRef.current) return;
52+
setScrollLeft(containerRef.current?.scrollLeft ?? 0);
53+
}, 200);
54+
55+
// init update containerRef details on mount and resize
56+
useLayoutEffect(() => {
57+
if (!containerRef.current) return;
58+
59+
const updateSize = throttledDebounce(() => {
60+
setCarouselWidth(containerRef.current?.clientWidth ?? 0);
61+
setScrollableWidth(containerRef.current?.scrollWidth ?? 0);
62+
setScrollLeft(containerRef.current?.scrollLeft ?? 0);
63+
console.log("i updated size", "width", containerRef.current?.clientWidth, "scrollable", containerRef.current?.scrollWidth)
64+
}, 200);
65+
66+
const resizeObserver = new ResizeObserver(updateSize);
67+
resizeObserver.observe(containerRef.current);
68+
69+
// Initial size update
70+
updateSize();
71+
72+
return () => {
73+
if (containerRef.current) {
74+
resizeObserver.unobserve(containerRef.current);
75+
}
76+
};
77+
}, []);
78+
79+
// update scroll position on scroll
80+
useLayoutEffect(() => {
81+
if (!containerRef.current) return;
82+
83+
containerRef.current?.addEventListener('scroll', handleScroll);
84+
85+
return () => {
86+
if (containerRef.current) {
87+
containerRef.current.removeEventListener('scroll', handleScroll);
88+
}
89+
};
90+
}, []);
91+
92+
const totalCarouselItems = useMemo(() => {
93+
console.log(containerRef.current)
94+
return containerRef.current?.children.length ?? 0
95+
}, [containerRef])
96+
97+
const goToNextSlide = useCallback(() => {
98+
if (!containerRef.current) return;
99+
const stepWidth = containerRef.current.clientWidth * stepWidthInPercent / 100
100+
const responsiveStepWidth = stepWidth < containerRef.current.children[0].clientWidth ? containerRef.current.clientWidth : stepWidth;
101+
const scrollLeft = containerRef.current.scrollLeft + responsiveStepWidth;
102+
containerRef.current.scrollTo({
103+
left: scrollLeft,
104+
behavior: 'smooth',
105+
});
106+
}, [containerRef, stepWidthInPercent]);
107+
108+
const goToPreviousSlide = useCallback(() => {
109+
if (!containerRef.current) return;
110+
const stepWidth = containerRef.current.clientWidth * stepWidthInPercent / 100
111+
// const responsiveStepWidth = Math.max(containerRef.current.clientWidth, containerRef.current.clientWidth * stepWidthInPercent / 100) ;
112+
const responsiveStepWidth = stepWidth < containerRef.current.children[0].clientWidth ? containerRef.current.clientWidth : stepWidth;
113+
const scrollLeft = Math.max(0, containerRef.current.scrollLeft - responsiveStepWidth);
114+
containerRef.current.scrollTo({
115+
left: scrollLeft,
116+
behavior: 'smooth',
117+
});
118+
}, [containerRef, stepWidthInPercent]);
119+
120+
return (
121+
<CarouselContext.Provider value={{containerRef, totalCarouselItems, goToNextSlide, goToPreviousSlide, possibleDirection }}>
122+
{children}
123+
</CarouselContext.Provider>
124+
)
125+
}
126+
127+
export const Carousel: React.FC<Omit<CarouselProviderProps, 'containerRef'>> & {
128+
Container: React.FC<CarouselContainerProps>;
129+
Item: React.FC<CarouselItemProps>;
130+
Controls: React.FC<CarouselControlProps>;
131+
PreviousButton: React.FC<CarouselButtonProps>;
132+
NextButton: React.FC<CarouselButtonProps>;
133+
} = ({ children, config }) => {
134+
const containerRef = useRef<HTMLDivElement>(null)
135+
return (
136+
<CarouselProvider containerRef={containerRef} config={config}>
137+
{children}
138+
</CarouselProvider>
139+
)
140+
}
141+
142+
Carousel.Container = CarouselContainer;
143+
Carousel.Item = CarouselItem;
144+
Carousel.Controls = CarouselControls;
145+
Carousel.PreviousButton = CarouselPreviousButton;
146+
Carousel.NextButton = CarouselNextButton;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from 'react'
2+
import { CarouselContextType, useCarousel } from './Carousel';
3+
import { ComponentStylePrimitiveProps } from '../../primitives/types';
4+
5+
export interface CarouselContainerProps extends ComponentStylePrimitiveProps<HTMLDivElement> {
6+
children: React.ReactNode
7+
}
8+
9+
export const CarouselContainer: React.FC<CarouselContainerProps> = ({ children, ...props }) => {
10+
const { className, ...rest } = props
11+
const { containerRef } = useCarousel();
12+
return (
13+
<div ref={containerRef} className={`max-w-full h-full flex overflow-scroll gap-2 no-scrollbar ${className}`} {...rest}>
14+
{children}
15+
</div>
16+
)
17+
}
18+
19+
export interface CarouselItemProps extends CarouselContainerProps { }
20+
21+
export const CarouselItem: React.FC<CarouselItemProps> = ({ children, ...props }) => {
22+
const { className, ...rest } = props
23+
return (
24+
<div className={`flex-shrink-0 relative ${className}`} {...rest}>
25+
{children}
26+
</div>
27+
)
28+
}
29+
30+
export interface CarouselControlProps extends ComponentStylePrimitiveProps<HTMLDivElement> {
31+
children: React.ReactNode
32+
}
33+
34+
export const CarouselControls: React.FC<CarouselControlProps> = ({ children, className, ...props }) => {
35+
return (
36+
<div className={`flex items-center gap-2 md:gap-4 w-fit mx-auto pt-4 ${className}`} {...props}>
37+
{children}
38+
</div>
39+
)
40+
}
41+
export interface CarouselButtonProps extends Omit<ComponentStylePrimitiveProps<HTMLButtonElement>, 'children'> {
42+
children?: React.ReactNode | ((goToPreviousSlide: () => void, possibleDirection: CarouselContextType['possibleDirection']) => React.ReactNode);
43+
icon: React.ReactNode;
44+
}
45+
46+
export const CarouselPreviousButton: React.FC<CarouselButtonProps> = ({ children, ...props }) => {
47+
const { goToPreviousSlide, possibleDirection } = useCarousel();
48+
49+
if (children) {
50+
if (typeof children === 'function') {
51+
return <>{children(goToPreviousSlide, possibleDirection)}</>;
52+
} else {
53+
console.warn('CarouselPreviousButton: Children prop is not a function (opts out of navigation logic). Rendering children as-is.');
54+
return <>{children}</>;
55+
}
56+
}
57+
58+
const { icon, className, ...rest } = props
59+
60+
return (
61+
<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}>
62+
{icon}
63+
</button>
64+
);
65+
};
66+
67+
export const CarouselNextButton: React.FC<CarouselButtonProps> = ({ children, ...props }) => {
68+
const { goToNextSlide, possibleDirection } = useCarousel();
69+
70+
if (children) {
71+
if (typeof children === 'function') {
72+
return <>{children(goToNextSlide, possibleDirection)}</>;
73+
} else {
74+
console.warn('CarouselNextButton: Children prop is not a function (opts out of navigation logic). Rendering children as-is.');
75+
return <>{children}</>;
76+
}
77+
}
78+
79+
const { icon, className, ...rest } = props
80+
81+
return (
82+
<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}>
83+
{icon}
84+
</button>
85+
);
86+
};
87+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type CarouselConfig = {
2+
stepWidthInPercent: number;
3+
// TODO: Add support for scrollSteps
4+
// scrollSteps?: number;
5+
};
6+
7+
export const DefaultCarouselConfig: CarouselConfig = {
8+
stepWidthInPercent: 100,
9+
};

src/components/carousel/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Carousel, type CarouselContextType } from "./Carousel";

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './components/button';
2-
export * from './components/footer';
2+
export * from './components/footer';
3+
export * from './components/carousel';

src/primitives/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ComponentStylePrimitiveProps<T>
2+
extends React.HTMLAttributes<T> {
3+
className?: string;
4+
}

src/styles/tailwind.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
@tailwind components;
44
@tailwind utilities;
55

6+
@layer utilities {
7+
/* Hide scrollbar for Chrome, Safari and Opera */
8+
.no-scrollbar::-webkit-scrollbar {
9+
display: none;
10+
}
11+
/* Hide scrollbar for IE, Edge and Firefox */
12+
.no-scrollbar {
13+
-ms-overflow-style: none; /* IE and Edge */
14+
scrollbar-width: none; /* Firefox */
15+
}
16+
}
17+
618
:root {
719
--foreground-rgb: 0, 0, 0;
820
--background-start-rgb: 214, 219, 220;

src/utils/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export function debounce<T extends (...args: any[]) => void>(
2+
func: T,
3+
wait: number
4+
): (...args: Parameters<T>) => void {
5+
let timeout: ReturnType<typeof setTimeout> | null = null;
6+
7+
return function(this: any, ...args: Parameters<T>) {
8+
const context = this;
9+
10+
const later = () => {
11+
timeout = null;
12+
func.apply(context, args);
13+
};
14+
15+
if (timeout !== null) {
16+
clearTimeout(timeout);
17+
}
18+
timeout = setTimeout(later, wait);
19+
};
20+
}
21+
22+
export function throttledDebounce<T extends (...args: any[]) => void>(
23+
func: T,
24+
limit: number
25+
): (...args: Parameters<T>) => void {
26+
let inThrottle: boolean = false;
27+
let lastArgs: Parameters<T> | null = null;
28+
29+
return function(this: any, ...args: Parameters<T>) {
30+
const context = this;
31+
32+
if (!inThrottle) {
33+
func.apply(context, args);
34+
inThrottle = true;
35+
setTimeout(() => {
36+
inThrottle = false;
37+
if (lastArgs) {
38+
func.apply(context, lastArgs);
39+
lastArgs = null;
40+
}
41+
}, limit);
42+
} else {
43+
lastArgs = args;
44+
}
45+
};
46+
}

0 commit comments

Comments
 (0)