Skip to content

Commit

Permalink
Merge pull request #972 from tszhong0411/pack-27-add-carousel
Browse files Browse the repository at this point in the history
Add Carousel
  • Loading branch information
tszhong0411 authored Jan 14, 2025
2 parents 9f0bc65 + ae5999b commit c24456f
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/quick-cameras-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tszhong0411/ui': patch
---

Add Carousel
1 change: 1 addition & 0 deletions .cspell/libraries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ contentlayer
corepack
dbaeumer
dockercompose
embla
esbenp
fuma
fumadocs
Expand Down
30 changes: 30 additions & 0 deletions apps/docs/src/app/ui/components/carousel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
title: Carousel
description: A carousel with motion and swipe built using Embla.
---

<ComponentPreview name='carousel/carousel' />

## Usage

```tsx
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious
} from '@tszhong0411/ui'
```

```tsx
<Carousel>
<CarouselContent>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
```
34 changes: 34 additions & 0 deletions apps/docs/src/components/demos/carousel/carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
Card,
CardContent,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious
} from '@tszhong0411/ui'
import { range } from '@tszhong0411/utils'

const CarouselDemo = () => {
return (
<Carousel className='w-full max-w-md'>
<CarouselContent>
{range(5).map((number) => (
<CarouselItem key={number}>
<div className='p-1'>
<Card>
<CardContent className='flex aspect-square items-center justify-center p-6'>
<span className='text-4xl font-semibold'>{number + 1}</span>
</CardContent>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
)
}

export default CarouselDemo
4 changes: 4 additions & 0 deletions apps/docs/src/config/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const COMPONENT_LINKS = [
href: '/ui/components/card',
text: 'Card'
},
{
href: '/ui/components/carousel',
text: 'Carousel'
},
{
href: '/ui/components/checkbox',
text: 'Checkbox'
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@radix-ui/react-visually-hidden": "^1.1.1",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.0.4",
"embla-carousel-react": "^8.5.2",
"framer-motion": "12.0.0-alpha.2",
"lucide-react": "^0.469.0",
"merge-refs": "^1.3.0",
Expand Down
231 changes: 231 additions & 0 deletions packages/ui/src/carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
'use client'

import { cn } from '@tszhong0411/utils'
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { createContext, useCallback, useContext, useEffect, useState } from 'react'

import { Button } from './button'

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]

type CarouselProps = {
options?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
setApi?: (api: CarouselApi) => void
}

type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps

const CarouselContext = createContext<CarouselContextProps | null>(null)

const useCarousel = () => {
const context = useContext(CarouselContext)

if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}

return context
}

type CarouselRootProps = React.ComponentProps<'div'> & CarouselProps

export const Carousel = (props: CarouselRootProps) => {
const {
orientation = 'horizontal',
options,
setApi,
plugins,
className,
children,
...rest
} = props
const [carouselRef, api] = useEmblaCarousel(
{
...options,
axis: orientation === 'horizontal' ? 'x' : 'y'
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = useState(false)
const [canScrollNext, setCanScrollNext] = useState(false)

const onSelect = useCallback((a: CarouselApi) => {
if (!a) {
return
}

setCanScrollPrev(a.canScrollPrev())
setCanScrollNext(a.canScrollNext())
}, [])

const scrollPrev = useCallback(() => {
api?.scrollPrev()
}, [api])

const scrollNext = useCallback(() => {
api?.scrollNext()
}, [api])

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollPrev()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)

useEffect(() => {
if (!api || !setApi) {
return
}

setApi(api)
}, [api, setApi])

useEffect(() => {
if (!api) {
return
}

onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)

return () => {
api.off('select', onSelect)
}
}, [api, onSelect])

return (
<CarouselContext
value={{
carouselRef,
api: api,
options,
orientation,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role='region'
aria-roledescription='carousel'
{...rest}
>
{children}
</div>
</CarouselContext>
)
}

type CarouselContentProps = React.ComponentProps<'div'>

export const CarouselContent = (props: CarouselContentProps) => {
const { className, ...rest } = props
const { carouselRef, orientation } = useCarousel()

return (
<div ref={carouselRef} className='overflow-hidden'>
<div
className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)}
{...rest}
/>
</div>
)
}

type CarouselItemProps = React.ComponentProps<'div'>

export const CarouselItem = (props: CarouselItemProps) => {
const { className, ...rest } = props
const { orientation } = useCarousel()

return (
<div
role='group'
aria-roledescription='slide'
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...rest}
/>
)
}

type CarouselPreviousProps = React.ComponentProps<typeof Button>

export const CarouselPrevious = (props: CarouselPreviousProps) => {
const { className, variant = 'outline', size = 'icon', ...rest } = props
const { orientation, scrollPrev, canScrollPrev } = useCarousel()

return (
<Button
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
aria-label='Previous slide'
{...rest}
>
<ArrowLeft className='size-4' />
</Button>
)
}

type CarouselNextProps = React.ComponentProps<typeof Button>

export const CarouselNext = (props: CarouselNextProps) => {
const { className, variant = 'outline', size = 'icon', ...rest } = props
const { orientation, scrollNext, canScrollNext } = useCarousel()

return (
<Button
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
aria-label='Next slide'
{...rest}
>
<ArrowRight className='size-4' />
</Button>
)
}
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './breadcrumb'
export * from './button'
export * from './callout'
export * from './card'
export * from './carousel'
export * from './checkbox'
export * from './code-block'
export * from './collapsible'
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c24456f

Please sign in to comment.