Skip to content

Commit

Permalink
Add reusable carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
kattylucy committed Jan 30, 2025
1 parent 35f4c7d commit a6ef4d3
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 13 deletions.
38 changes: 38 additions & 0 deletions centrifuge-app/src/pages/Dashboard/hooks/useCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { RefObject, useEffect, useState } from 'react'

export function useCarousel<T>(
containerRef: RefObject<HTMLElement>,
items: T[],
itemWidth: number,
debounceDelay: number = 100
): boolean {
const [useCarousel, setUseCarousel] = useState(false)

useEffect(() => {
let debounceTimeout: NodeJS.Timeout

const checkWrapping = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth
const totalItemWidth = items.length * itemWidth
setUseCarousel(totalItemWidth > containerWidth)
}
}

const handleResize = () => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(checkWrapping, debounceDelay)
}

checkWrapping()

window.addEventListener('resize', handleResize)

return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(debounceTimeout)
}
}, [containerRef, items, itemWidth, debounceDelay])

return useCarousel
}
168 changes: 168 additions & 0 deletions fabric/src/components/Carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { TouchEvent, useEffect, useState } from 'react'
import styled from 'styled-components'
import { IconChevronLeft, IconChevronRight } from '../../icon'
import { Box } from '../Box'

interface CarouselProps {
children: React.ReactNode[]
visibleItems?: number
itemWidth?: number
gap?: number
}

const CarouselContainer = styled(Box)`
position: relative;
display: flex;
align-items: center;
`

const CarouselViewport = styled(Box)`
overflow: hidden;
margin-left: 40px;
margin-right: 40px;
`

const CarouselItems = styled(Box)<{ translateX: number }>`
display: flex;
transform: ${({ translateX }) => `translateX(-${translateX}px)`};
transition: transform 0.3s ease-in-out;
margin-right: 16px;
`

const CarouselItem = styled(Box)<{ itemWidth: number; gap: number }>`
flex: 0 0 auto;
min-width: ${({ itemWidth }) => itemWidth}px;
margin-right: ${({ gap }) => gap}px;
&:last-child {
margin-right: 0;
}
`

const CarouselArrow = styled.button<{ position: 'left' | 'right' }>`
background-color: transparent;
color: ${({ theme }) => theme.colors.backgroundInverted};
border: none;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
position: absolute;
top: 50%;
${({ position }) => position}: 8px;
transform: translateY(-50%);
transition: background-color 0.3s;
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`

export const Carousel = ({ children, visibleItems = 3, itemWidth = 200, gap = 16 }: CarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0)
const [dynamicVisibleItems, setDynamicVisibleItems] = useState(visibleItems)
const totalItems = children.length
const maxIndex = Math.max(totalItems - dynamicVisibleItems, 0)

useEffect(() => {
const updateVisibleItems = () => {
const width = window.innerWidth
if (width < 600) {
setDynamicVisibleItems(1)
} else if (width >= 600 && width < 900) {
setDynamicVisibleItems(2)
} else {
setDynamicVisibleItems(3)
}
}

updateVisibleItems()

window.addEventListener('resize', updateVisibleItems)
return () => window.removeEventListener('resize', updateVisibleItems)
}, [])

useEffect(() => {
setCurrentIndex((prev) => Math.min(prev, Math.max(totalItems - dynamicVisibleItems, 0)))
}, [dynamicVisibleItems, totalItems])

const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0))
}

const handleNext = () => {
setCurrentIndex((prev) => Math.min(prev + 1, maxIndex))
}

const [touchStartX, setTouchStartX] = useState<number | null>(null)
const [touchEndX, setTouchEndX] = useState<number | null>(null)

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
handlePrev()
} else if (e.key === 'ArrowRight') {
handleNext()
}
}

window.addEventListener('keydown', handleKeyDown)

return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [])

const onTouchStart = (e: TouchEvent) => {
setTouchStartX(e.changedTouches[0].screenX)
}

const onTouchMove = (e: TouchEvent) => {
setTouchEndX(e.changedTouches[0].screenX)
}

const onTouchEnd = () => {
if (touchStartX === null || touchEndX === null) return
const distance = touchStartX - touchEndX
const threshold = 50
if (distance > threshold) {
handleNext()
} else if (distance < -threshold) {
handlePrev()
}
setTouchStartX(null)
setTouchEndX(null)
}

return (
<CarouselContainer onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}>
{currentIndex > 0 && (
<CarouselArrow position="left" onClick={handlePrev} aria-label="Previous Slide">
<IconChevronLeft size={18} />
</CarouselArrow>
)}

<CarouselViewport>
<CarouselItems translateX={currentIndex * (itemWidth + gap)} id="carousel-items">
{children.map((child, index) => (
<CarouselItem key={index} itemWidth={itemWidth} gap={gap}>
{child}
</CarouselItem>
))}
</CarouselItems>
</CarouselViewport>

{currentIndex < maxIndex && (
<CarouselArrow position="right" onClick={handleNext} aria-label="Next Slide">
<IconChevronRight size={18} />
</CarouselArrow>
)}
</CarouselContainer>
)
}
34 changes: 21 additions & 13 deletions fabric/src/components/Checkbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: string | React.ReactElement
errorMessage?: string
extendedClickArea?: boolean
variant?: 'primary' | 'secondary'
}

export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxProps }: CheckboxProps) {
export function Checkbox({
label,
errorMessage,
extendedClickArea,
variant = 'primary',
...checkboxProps
}: CheckboxProps) {
return (
<Box position="relative">
<StyledLabel $extendedClickArea={!!extendedClickArea}>
<Shelf as={Text} gap={1} alignItems="flex-start" position="relative">
<StyledWrapper minWidth="18px" height="18px" flex="0 0 18px" $hasLabel={!!label}>
<StyledCheckbox type="checkbox" {...checkboxProps} />
<StyledCheckbox type="checkbox" {...checkboxProps} variant={variant} />
<StyledOutline />
</StyledWrapper>
{label && (
Expand Down Expand Up @@ -88,30 +95,31 @@ const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>`
}
`

const StyledCheckbox = styled.input`
width: 18px;
height: 18px;
const StyledCheckbox = styled.input<{ variant: 'primary' | 'secondary' }>`
width: 16px;
height: 16px;
appearance: none;
border-radius: 2px;
border: 1px solid ${({ theme }) => theme.colors.borderPrimary};
border-radius: 4px;
border: 1px solid
${({ theme, variant }) => (variant === 'primary' ? theme.colors.borderPrimary : theme.colors.textPrimary)};
position: relative;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
${({ theme }) => `
${({ theme, variant }) => `
&:checked {
border-color: ${theme.colors.borderSecondary};
background-color: ${theme.colors.textGold};
border-color: ${variant === 'primary' ? theme.colors.borderSecondary : theme.colors.textPrimary};
background-color: ${variant === 'primary' ? theme.colors.textGold : 'white'};
}
&:checked::after {
content: '';
position: absolute;
top: 2px;
left: 5px;
width: 6px;
height: 10px;
border: solid white;
width: 4px;
height: 8px;
border: solid ${variant === 'primary' ? 'white' : 'black'};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
Expand Down
1 change: 1 addition & 0 deletions fabric/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './components/BetaChip'
export * from './components/Box'
export * from './components/Button'
export * from './components/Card'
export * from './components/Carousel'
export * from './components/Checkbox'
export * from './components/Collapsible'
export * from './components/Container'
Expand Down

0 comments on commit a6ef4d3

Please sign in to comment.