-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
6604f36
commit 8885951
Showing
9 changed files
with
365 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { Carousel, type CarouselContextType } from "./Carousel"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export interface ComponentStylePrimitiveProps<T> | ||
extends React.HTMLAttributes<T> { | ||
className?: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}; | ||
} |