Skip to content

Commit 6112e9b

Browse files
committed
feat: added carousel component
1 parent 2d3fc68 commit 6112e9b

File tree

17 files changed

+924
-2
lines changed

17 files changed

+924
-2
lines changed

.eslintrc.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
22
"root": true,
3-
"extends": ["eslint:recommended", "plugin:solid/recommended", "plugin:tailwindcss/recommended"],
3+
"extends": [
4+
"eslint:recommended",
5+
"plugin:@typescript-eslint/eslint-recommended",
6+
"plugin:@typescript-eslint/recommended",
7+
"plugin:solid/recommended",
8+
"plugin:tailwindcss/recommended"
9+
],
410
"plugins": ["solid", "tailwindcss"],
511
"rules": {
612
"tailwindcss/no-custom-classname": "off",

apps/docs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"class-variance-authority": "^0.7.0",
2626
"clsx": "^2.0.0",
2727
"corvu": "^0.4.0",
28+
"embla-carousel-autoplay": "^8.0.0",
29+
"embla-carousel-solid": "^8.0.0",
2830
"shosho": "^1.4.3",
2931
"solid-icons": "^1.0.12",
3032
"solid-js": "1.7.12",

apps/docs/public/registry/index.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@
8585
],
8686
"type": "ui"
8787
},
88+
{
89+
"name": "carousel",
90+
"dependencies": [
91+
"embla-carousel-solid"
92+
],
93+
"files": [
94+
"ui/carousel.tsx"
95+
],
96+
"type": "ui"
97+
},
8898
{
8999
"name": "charts",
90100
"dependencies": [
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "carousel",
3+
"dependencies": [
4+
"embla-carousel-solid"
5+
],
6+
"files": [
7+
{
8+
"name": "carousel.tsx",
9+
"content": "import type { Accessor, Component, ComponentProps, VoidProps } from \"solid-js\"\nimport {\n createContext,\n createEffect,\n createMemo,\n createSignal,\n mergeProps,\n splitProps,\n useContext\n} from \"solid-js\"\n\nimport type { CreateEmblaCarouselType } from \"embla-carousel-solid\"\nimport createEmblaCarousel from \"embla-carousel-solid\"\nimport { TbArrowLeft, TbArrowRight } from \"solid-icons/tb\"\n\nimport { cn } from \"~/lib/utils\"\nimport { Button, ButtonProps } from \"~/registry/ui/button\"\n\nexport type CarouselApi = CreateEmblaCarouselType[1]\n\ntype UseCarouselParameters = Parameters<typeof createEmblaCarousel>\ntype CarouselOptions = NonNullable<UseCarouselParameters[0]>\ntype CarouselPlugin = NonNullable<UseCarouselParameters[1]>\n\ntype CarouselProps = {\n opts?: ReturnType<CarouselOptions>\n plugins?: ReturnType<CarouselPlugin>\n orientation?: \"horizontal\" | \"vertical\"\n setApi?: (api: CarouselApi) => void\n}\n\ntype CarouselContextProps = {\n carouselRef: ReturnType<typeof createEmblaCarousel>[0]\n api: ReturnType<typeof createEmblaCarousel>[1]\n scrollPrev: () => void\n scrollNext: () => void\n canScrollPrev: Accessor<boolean>\n canScrollNext: Accessor<boolean>\n} & CarouselProps\n\nconst CarouselContext = createContext<Accessor<CarouselContextProps> | null>(null)\n\nconst useCarousel = () => {\n const context = useContext(CarouselContext)\n\n if (!context) {\n throw new Error(\"useCarousel must be used within a <Carousel />\")\n }\n\n return context()\n}\n\nconst Carousel: Component<CarouselProps & ComponentProps<\"div\">> = (rawProps) => {\n const props = mergeProps<(CarouselProps & ComponentProps<\"div\">)[]>(\n { orientation: \"horizontal\" },\n rawProps\n )\n\n const [, rest] = splitProps(props, [\n \"orientation\",\n \"opts\",\n \"setApi\",\n \"plugins\",\n \"class\",\n \"children\"\n ])\n\n const [carouselRef, api] = createEmblaCarousel(\n () => ({\n ...props.opts,\n axis: props.orientation === \"horizontal\" ? \"x\" : \"y\"\n }),\n () => (props.plugins === undefined ? [] : props.plugins)\n )\n const [canScrollPrev, setCanScrollPrev] = createSignal(false)\n const [canScrollNext, setCanScrollNext] = createSignal(false)\n\n const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => {\n setCanScrollPrev(api.canScrollPrev())\n setCanScrollNext(api.canScrollNext())\n }\n\n const scrollPrev = () => {\n api()?.scrollPrev()\n }\n\n const scrollNext = () => {\n api()?.scrollNext()\n }\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"ArrowLeft\") {\n event.preventDefault()\n scrollPrev()\n } else if (event.key === \"ArrowRight\") {\n event.preventDefault()\n scrollNext()\n }\n }\n\n createEffect(() => {\n if (!api() || !props.setApi) {\n return\n }\n\n props.setApi(api)\n })\n\n createEffect(() => {\n if (!api()) {\n return\n }\n\n onSelect(api()!)\n api()!.on(\"reInit\", onSelect)\n api()!.on(\"select\", onSelect)\n\n return () => {\n api()?.off(\"select\", onSelect)\n }\n })\n\n const value = createMemo(\n () =>\n ({\n carouselRef,\n api,\n opts: props.opts,\n orientation: props.orientation || (props.opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n scrollPrev,\n scrollNext,\n canScrollPrev,\n canScrollNext\n }) satisfies CarouselContextProps\n )\n\n return (\n <CarouselContext.Provider value={value}>\n <div\n onKeyDown={handleKeyDown}\n class={cn(\"relative\", props.class)}\n role=\"region\"\n aria-roledescription=\"carousel\"\n {...rest}\n >\n {props.children}\n </div>\n </CarouselContext.Provider>\n )\n}\n\nconst CarouselContent: Component<ComponentProps<\"div\">> = (props) => {\n const [, rest] = splitProps(props, [\"class\"])\n const { carouselRef, orientation } = useCarousel()\n\n return (\n <div ref={carouselRef} class=\"overflow-hidden\">\n <div\n class={cn(\"flex\", orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\", props.class)}\n {...rest}\n />\n </div>\n )\n}\n\nconst CarouselItem: Component<ComponentProps<\"div\">> = (props) => {\n const [, rest] = splitProps(props, [\"class\"])\n const { orientation } = useCarousel()\n\n return (\n <div\n role=\"group\"\n aria-roledescription=\"slide\"\n class={cn(\n \"min-w-0 shrink-0 grow-0 basis-full\",\n orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n props.class\n )}\n {...rest}\n />\n )\n}\n\ntype CarouselButtonProps = VoidProps<ButtonProps>\n\nconst CarouselPrevious: Component<CarouselButtonProps> = (rawProps) => {\n const props = mergeProps<CarouselButtonProps[]>({ variant: \"outline\", size: \"icon\" }, rawProps)\n const [, rest] = splitProps(props, [\"class\", \"variant\", \"size\"])\n const { orientation, scrollPrev, canScrollPrev } = useCarousel()\n\n return (\n <Button\n variant={props.variant}\n size={props.size}\n class={cn(\n \"absolute size-8 rounded-full\",\n orientation === \"horizontal\"\n ? \"-left-12 top-1/2 -translate-y-1/2\"\n : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n props.class\n )}\n disabled={!canScrollPrev()}\n onClick={scrollPrev}\n {...rest}\n >\n <TbArrowLeft class=\"size-4\" />\n <span class=\"sr-only\">Previous slide</span>\n </Button>\n )\n}\n\nconst CarouselNext: Component<CarouselButtonProps> = (rawProps) => {\n const props = mergeProps<CarouselButtonProps[]>({ variant: \"outline\", size: \"icon\" }, rawProps)\n const [, rest] = splitProps(props, [\"class\", \"variant\", \"size\"])\n const { orientation, scrollNext, canScrollNext } = useCarousel()\n\n return (\n <Button\n variant={props.variant}\n size={props.size}\n class={cn(\n \"absolute size-8 rounded-full\",\n orientation === \"horizontal\"\n ? \"-right-12 top-1/2 -translate-y-1/2\"\n : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n props.class\n )}\n disabled={!canScrollNext()}\n onClick={scrollNext}\n {...rest}\n >\n <TbArrowRight class=\"size-4\" />\n <span class=\"sr-only\">Next slide</span>\n </Button>\n )\n}\n\nexport { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }\n"
10+
}
11+
],
12+
"type": "ui"
13+
}

apps/docs/src/__registry__/index.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export const Index: Record<string, any> = {
6969
component: lazy(() => import("~/registry/ui/card")),
7070
files: ["registry/ui/card.tsx"],
7171
},
72+
"carousel": {
73+
name: "carousel",
74+
type: "ui",
75+
registryDependencies: undefined,
76+
component: lazy(() => import("~/registry/ui/carousel")),
77+
files: ["registry/ui/carousel.tsx"],
78+
},
7279
"charts": {
7380
name: "charts",
7481
type: "ui",
@@ -370,6 +377,48 @@ export const Index: Record<string, any> = {
370377
component: lazy(() => import("~/registry/example/card-demo")),
371378
files: ["registry/example/card-demo.tsx"],
372379
},
380+
"carousel-demo": {
381+
name: "carousel-demo",
382+
type: "example",
383+
registryDependencies: undefined,
384+
component: lazy(() => import("~/registry/example/carousel-demo")),
385+
files: ["registry/example/carousel-demo.tsx"],
386+
},
387+
"carousel-size-demo": {
388+
name: "carousel-size-demo",
389+
type: "example",
390+
registryDependencies: undefined,
391+
component: lazy(() => import("~/registry/example/carousel-size-demo")),
392+
files: ["registry/example/carousel-size-demo.tsx"],
393+
},
394+
"carousel-api-demo": {
395+
name: "carousel-api-demo",
396+
type: "example",
397+
registryDependencies: undefined,
398+
component: lazy(() => import("~/registry/example/carousel-api-demo")),
399+
files: ["registry/example/carousel-api-demo.tsx"],
400+
},
401+
"carousel-orientation-demo": {
402+
name: "carousel-orientation-demo",
403+
type: "example",
404+
registryDependencies: undefined,
405+
component: lazy(() => import("~/registry/example/carousel-orientation-demo")),
406+
files: ["registry/example/carousel-orientation-demo.tsx"],
407+
},
408+
"carousel-plugin-demo": {
409+
name: "carousel-plugin-demo",
410+
type: "example",
411+
registryDependencies: undefined,
412+
component: lazy(() => import("~/registry/example/carousel-plugin-demo")),
413+
files: ["registry/example/carousel-plugin-demo.tsx"],
414+
},
415+
"carousel-spacing-demo": {
416+
name: "carousel-spacing-demo",
417+
type: "example",
418+
registryDependencies: undefined,
419+
component: lazy(() => import("~/registry/example/carousel-spacing-demo")),
420+
files: ["registry/example/carousel-spacing-demo.tsx"],
421+
},
373422
"area-chart-demo": {
374423
name: "area-chart-demo",
375424
type: "example",

apps/docs/src/components/mdx-components.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type ComponentProps } from "solid-js"
33
import { ComponentPreview } from "~/components/component-preview"
44
import { ComponentSource } from "~/components/component-source"
55
import { CopyButton } from "~/components/copy-button"
6+
import { Alert, AlertDescription, AlertTitle } from "~/registry/ui/alert"
67
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/registry/ui/tabs"
78

89
export const MDXComponents = {
@@ -116,5 +117,8 @@ export const MDXComponents = {
116117
/>
117118
),
118119
ComponentPreview,
119-
ComponentSource
120+
ComponentSource,
121+
Alert,
122+
AlertTitle,
123+
AlertDescription
120124
}

apps/docs/src/config/docs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export const docsConfig: Config = {
112112
title: "Card",
113113
href: "/docs/components/card"
114114
},
115+
{
116+
title: "Carousel",
117+
href: "/docs/components/carousel"
118+
},
115119
{
116120
title: "Charts",
117121
href: "/docs/components/charts"
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createEffect, createSignal, Index } from "solid-js"
2+
3+
import { Card, CardContent } from "~/registry/ui/card"
4+
import {
5+
Carousel,
6+
CarouselContent,
7+
CarouselItem,
8+
CarouselNext,
9+
CarouselPrevious,
10+
type CarouselApi
11+
} from "~/registry/ui/carousel"
12+
13+
export default function CarouselApiDemo() {
14+
const [api, setApi] = createSignal<ReturnType<CarouselApi>>()
15+
const [current, setCurrent] = createSignal(0)
16+
const [count, setCount] = createSignal(0)
17+
18+
const onSelect = () => {
19+
setCurrent(api()!.selectedScrollSnap() + 1)
20+
}
21+
22+
createEffect(() => {
23+
if (!api()) {
24+
return
25+
}
26+
27+
setCount(api()!.scrollSnapList().length)
28+
setCurrent(api()!.selectedScrollSnap() + 1)
29+
30+
api()!.on("select", onSelect)
31+
})
32+
33+
return (
34+
<div>
35+
<Carousel setApi={setApi} class="w-full max-w-xs">
36+
<CarouselContent>
37+
<Index each={Array.from({ length: 5 })}>
38+
{(_, index) => (
39+
<CarouselItem>
40+
<Card>
41+
<CardContent class="flex aspect-square items-center justify-center p-6">
42+
<span class="text-4xl font-semibold">{index + 1}</span>
43+
</CardContent>
44+
</Card>
45+
</CarouselItem>
46+
)}
47+
</Index>
48+
</CarouselContent>
49+
<CarouselPrevious />
50+
<CarouselNext />
51+
</Carousel>
52+
<div class="py-2 text-center text-sm text-muted-foreground">
53+
Slide {current()} of {count()}
54+
</div>
55+
</div>
56+
)
57+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Index } from "solid-js"
2+
3+
import { Card, CardContent } from "~/registry/ui/card"
4+
import {
5+
Carousel,
6+
CarouselContent,
7+
CarouselItem,
8+
CarouselNext,
9+
CarouselPrevious
10+
} from "~/registry/ui/carousel"
11+
12+
export default function CarouselDemo() {
13+
return (
14+
<Carousel class="w-full max-w-xs">
15+
<CarouselContent>
16+
<Index each={Array.from({ length: 5 })}>
17+
{(_, index) => (
18+
<CarouselItem>
19+
<div class="p-1">
20+
<Card>
21+
<CardContent class="flex aspect-square items-center justify-center p-6">
22+
<span class="text-4xl font-semibold">{index + 1}</span>
23+
</CardContent>
24+
</Card>
25+
</div>
26+
</CarouselItem>
27+
)}
28+
</Index>
29+
</CarouselContent>
30+
<CarouselPrevious />
31+
<CarouselNext />
32+
</Carousel>
33+
)
34+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Index } from "solid-js"
2+
3+
import { Card, CardContent } from "~/registry/ui/card"
4+
import {
5+
Carousel,
6+
CarouselContent,
7+
CarouselItem,
8+
CarouselNext,
9+
CarouselPrevious
10+
} from "~/registry/ui/carousel"
11+
12+
export default function CarouselOrientationDemo() {
13+
return (
14+
<Carousel
15+
opts={{
16+
align: "start"
17+
}}
18+
orientation="vertical"
19+
class="w-full max-w-xs"
20+
>
21+
<CarouselContent class="-mt-1 h-[200px]">
22+
<Index each={Array.from({ length: 5 })}>
23+
{(_, index) => (
24+
<CarouselItem class="pt-1 md:basis-1/2">
25+
<div class="p-1">
26+
<Card>
27+
<CardContent class="flex items-center justify-center p-6">
28+
<span class="text-3xl font-semibold">{index + 1}</span>
29+
</CardContent>
30+
</Card>
31+
</div>
32+
</CarouselItem>
33+
)}
34+
</Index>
35+
</CarouselContent>
36+
<CarouselPrevious />
37+
<CarouselNext />
38+
</Carousel>
39+
)
40+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Index } from "solid-js"
2+
3+
import Autoplay from "embla-carousel-autoplay"
4+
5+
import { Card, CardContent } from "~/registry/ui/card"
6+
import {
7+
Carousel,
8+
CarouselContent,
9+
CarouselItem,
10+
CarouselNext,
11+
CarouselPrevious
12+
} from "~/registry/ui/carousel"
13+
14+
export default function CarouselPluginDemo() {
15+
let plugin = Autoplay({ delay: 2000, stopOnInteraction: true })
16+
17+
return (
18+
<Carousel
19+
plugins={[plugin]}
20+
class="w-full max-w-xs"
21+
onMouseEnter={plugin.stop}
22+
onMouseLeave={() => plugin.play(false)}
23+
>
24+
<CarouselContent>
25+
<Index each={Array.from({ length: 5 })}>
26+
{(_, index) => (
27+
<CarouselItem>
28+
<div class="p-1">
29+
<Card>
30+
<CardContent class="flex aspect-square items-center justify-center p-6">
31+
<span class="text-4xl font-semibold">{index + 1}</span>
32+
</CardContent>
33+
</Card>
34+
</div>
35+
</CarouselItem>
36+
)}
37+
</Index>
38+
</CarouselContent>
39+
<CarouselPrevious />
40+
<CarouselNext />
41+
</Carousel>
42+
)
43+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Index } from "solid-js"
2+
3+
import { Card, CardContent } from "~/registry/ui/card"
4+
import {
5+
Carousel,
6+
CarouselContent,
7+
CarouselItem,
8+
CarouselNext,
9+
CarouselPrevious
10+
} from "~/registry/ui/carousel"
11+
12+
export default function CarouselSizeDemo() {
13+
return (
14+
<Carousel
15+
opts={{
16+
align: "start"
17+
}}
18+
class="w-full max-w-sm"
19+
>
20+
<CarouselContent>
21+
<Index each={Array.from({ length: 5 })}>
22+
{(_, index) => (
23+
<CarouselItem class="md:basis-1/2 lg:basis-1/3">
24+
<div class="p-1">
25+
<Card>
26+
<CardContent class="flex aspect-square items-center justify-center p-6">
27+
<span class="text-3xl font-semibold">{index + 1}</span>
28+
</CardContent>
29+
</Card>
30+
</div>
31+
</CarouselItem>
32+
)}
33+
</Index>
34+
</CarouselContent>
35+
<CarouselPrevious />
36+
<CarouselNext />
37+
</Carousel>
38+
)
39+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Index } from "solid-js"
2+
3+
import { Card, CardContent } from "~/registry/ui/card"
4+
import {
5+
Carousel,
6+
CarouselContent,
7+
CarouselItem,
8+
CarouselNext,
9+
CarouselPrevious
10+
} from "~/registry/ui/carousel"
11+
12+
export default function CarouselSpacingDemo() {
13+
return (
14+
<Carousel class="w-full max-w-sm">
15+
<CarouselContent class="-ml-1">
16+
<Index each={Array.from({ length: 5 })}>
17+
{(_, index) => (
18+
<CarouselItem class="pl-1 md:basis-1/2 lg:basis-1/3">
19+
<div class="p-1">
20+
<Card>
21+
<CardContent class="flex aspect-square items-center justify-center p-6">
22+
<span class="text-2xl font-semibold">{index + 1}</span>
23+
</CardContent>
24+
</Card>
25+
</div>
26+
</CarouselItem>
27+
)}
28+
</Index>
29+
</CarouselContent>
30+
<CarouselPrevious />
31+
<CarouselNext />
32+
</Carousel>
33+
)
34+
}

0 commit comments

Comments
 (0)