@moontra/moonui 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { Rating, ratingVariants } from '../rating'
5
+
6
+ describe('Rating Component', () => {
7
+ describe('Rendering', () => {
8
+ it('renders a radiogroup with the default aria-label', () => {
9
+ render(<Rating />)
10
+ const group = screen.getByRole('radiogroup')
11
+ expect(group).toBeInTheDocument()
12
+ expect(group).toHaveAttribute('aria-label', 'Rating')
13
+ })
14
+
15
+ it('renders the default number of stars (5)', () => {
16
+ const { container } = render(<Rating />)
17
+ const stars = container.querySelectorAll('[data-slot="rating-star"]')
18
+ expect(stars).toHaveLength(5)
19
+ })
20
+
21
+ it('renders a custom number of stars via max', () => {
22
+ const { container } = render(<Rating max={10} />)
23
+ const stars = container.querySelectorAll('[data-slot="rating-star"]')
24
+ expect(stars).toHaveLength(10)
25
+ })
26
+
27
+ it('renders one radio option per star in full precision', () => {
28
+ render(<Rating max={5} />)
29
+ expect(screen.getAllByRole('radio')).toHaveLength(5)
30
+ })
31
+
32
+ it('renders two radio options per star in half precision', () => {
33
+ render(<Rating max={5} precision="half" />)
34
+ expect(screen.getAllByRole('radio')).toHaveLength(10)
35
+ })
36
+
37
+ it('applies a custom className to the root', () => {
38
+ render(<Rating className="custom-rating" aria-label="Score" />)
39
+ expect(screen.getByRole('radiogroup')).toHaveClass('custom-rating')
40
+ })
41
+
42
+ it('respects a user-provided aria-label', () => {
43
+ render(<Rating aria-label="Product rating" />)
44
+ expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-label', 'Product rating')
45
+ })
46
+
47
+ it('forwards ref to the root div', () => {
48
+ const ref = React.createRef<HTMLDivElement>()
49
+ render(<Rating ref={ref} />)
50
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
51
+ })
52
+
53
+ it('maintains displayName', () => {
54
+ expect(Rating.displayName).toBe('Rating')
55
+ })
56
+ })
57
+
58
+ describe('Sizes', () => {
59
+ it('applies default (md) size gap', () => {
60
+ render(<Rating />)
61
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-1')
62
+ })
63
+
64
+ it('applies sm size gap', () => {
65
+ render(<Rating size="sm" />)
66
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-0.5')
67
+ })
68
+
69
+ it('applies lg size gap', () => {
70
+ render(<Rating size="lg" />)
71
+ expect(screen.getByRole('radiogroup')).toHaveClass('gap-1.5')
72
+ })
73
+
74
+ it('exposes ratingVariants helper', () => {
75
+ expect(typeof ratingVariants).toBe('function')
76
+ expect(ratingVariants({ size: 'lg' })).toContain('gap-1.5')
77
+ })
78
+ })
79
+
80
+ describe('Interaction', () => {
81
+ it('calls onValueChange with the clicked star value', () => {
82
+ const onValueChange = jest.fn()
83
+ render(<Rating onValueChange={onValueChange} />)
84
+ fireEvent.click(screen.getByRole('radio', { name: '3 stars' }))
85
+ expect(onValueChange).toHaveBeenCalledWith(3)
86
+ })
87
+
88
+ it('uses singular label for the first star', () => {
89
+ const onValueChange = jest.fn()
90
+ render(<Rating onValueChange={onValueChange} />)
91
+ fireEvent.click(screen.getByRole('radio', { name: '1 star' }))
92
+ expect(onValueChange).toHaveBeenCalledWith(1)
93
+ })
94
+
95
+ it('updates uncontrolled value on click (defaultValue)', () => {
96
+ render(<Rating defaultValue={2} />)
97
+ // 2 stars initially checked
98
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'true')
99
+ fireEvent.click(screen.getByRole('radio', { name: '4 stars' }))
100
+ expect(screen.getByRole('radio', { name: '4 stars' })).toHaveAttribute('aria-checked', 'true')
101
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'false')
102
+ })
103
+
104
+ it('supports half precision selection', () => {
105
+ const onValueChange = jest.fn()
106
+ render(<Rating precision="half" onValueChange={onValueChange} />)
107
+ fireEvent.click(screen.getByRole('radio', { name: '2.5 stars' }))
108
+ expect(onValueChange).toHaveBeenCalledWith(2.5)
109
+ })
110
+ })
111
+
112
+ describe('Controlled behavior', () => {
113
+ it('reflects the controlled value prop', () => {
114
+ render(<Rating value={4} />)
115
+ expect(screen.getByRole('radio', { name: '4 stars' })).toHaveAttribute('aria-checked', 'true')
116
+ expect(screen.getByRole('radio', { name: '3 stars' })).toHaveAttribute('aria-checked', 'false')
117
+ })
118
+
119
+ it('does not change the controlled value internally on click but fires callback', () => {
120
+ const onValueChange = jest.fn()
121
+ render(<Rating value={2} onValueChange={onValueChange} />)
122
+ fireEvent.click(screen.getByRole('radio', { name: '5 stars' }))
123
+ // callback fired
124
+ expect(onValueChange).toHaveBeenCalledWith(5)
125
+ // but the displayed selection stays controlled at 2
126
+ expect(screen.getByRole('radio', { name: '2 stars' })).toHaveAttribute('aria-checked', 'true')
127
+ expect(screen.getByRole('radio', { name: '5 stars' })).toHaveAttribute('aria-checked', 'false')
128
+ })
129
+ })
130
+
131
+ describe('Keyboard navigation', () => {
132
+ it('increments value with ArrowRight', () => {
133
+ const onValueChange = jest.fn()
134
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
135
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
136
+ expect(onValueChange).toHaveBeenCalledWith(4)
137
+ })
138
+
139
+ it('decrements value with ArrowLeft', () => {
140
+ const onValueChange = jest.fn()
141
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
142
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowLeft' })
143
+ expect(onValueChange).toHaveBeenCalledWith(2)
144
+ })
145
+
146
+ it('sets minimum with Home and maximum with End', () => {
147
+ const onValueChange = jest.fn()
148
+ render(<Rating defaultValue={3} onValueChange={onValueChange} />)
149
+ const group = screen.getByRole('radiogroup')
150
+ fireEvent.keyDown(group, { key: 'Home' })
151
+ expect(onValueChange).toHaveBeenCalledWith(1)
152
+ fireEvent.keyDown(group, { key: 'End' })
153
+ expect(onValueChange).toHaveBeenCalledWith(5)
154
+ })
155
+
156
+ it('clamps at max on ArrowRight', () => {
157
+ const onValueChange = jest.fn()
158
+ render(<Rating defaultValue={5} onValueChange={onValueChange} />)
159
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
160
+ expect(onValueChange).toHaveBeenCalledWith(5)
161
+ })
162
+
163
+ it('steps by 0.5 in half precision', () => {
164
+ const onValueChange = jest.fn()
165
+ render(<Rating defaultValue={3} precision="half" onValueChange={onValueChange} />)
166
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
167
+ expect(onValueChange).toHaveBeenCalledWith(3.5)
168
+ })
169
+
170
+ it('is focusable when interactive', () => {
171
+ render(<Rating />)
172
+ expect(screen.getByRole('radiogroup')).toHaveAttribute('tabindex', '0')
173
+ })
174
+ })
175
+
176
+ describe('ReadOnly mode', () => {
177
+ it('marks the group as aria-readonly and non-focusable', () => {
178
+ render(<Rating value={3} readOnly />)
179
+ const group = screen.getByRole('radiogroup')
180
+ expect(group).toHaveAttribute('aria-readonly', 'true')
181
+ expect(group).not.toHaveAttribute('tabindex')
182
+ })
183
+
184
+ it('renders no interactive radio options', () => {
185
+ render(<Rating value={3} readOnly />)
186
+ expect(screen.queryAllByRole('radio')).toHaveLength(0)
187
+ })
188
+
189
+ it('does not respond to keyboard input', () => {
190
+ const onValueChange = jest.fn()
191
+ render(<Rating value={3} readOnly onValueChange={onValueChange} />)
192
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' })
193
+ expect(onValueChange).not.toHaveBeenCalled()
194
+ })
195
+
196
+ it('still renders the star visuals', () => {
197
+ const { container } = render(<Rating value={3} readOnly />)
198
+ expect(container.querySelectorAll('[data-slot="rating-star"]')).toHaveLength(5)
199
+ })
200
+ })
201
+
202
+ describe('Custom icon', () => {
203
+ it('renders a custom icon instead of the default Star', () => {
204
+ const CustomIcon = <svg data-testid="custom-icon" />
205
+ const { container } = render(<Rating icon={CustomIcon} max={3} />)
206
+ // Both empty and filled layers per star -> 2 icons per star
207
+ expect(container.querySelectorAll('[data-testid="custom-icon"]').length).toBeGreaterThanOrEqual(3)
208
+ })
209
+ })
210
+
211
+ describe('HTML attributes', () => {
212
+ it('passes through arbitrary HTML attributes', () => {
213
+ render(<Rating data-testid="my-rating" id="rating-1" />)
214
+ const group = screen.getByTestId('my-rating')
215
+ expect(group).toHaveAttribute('id', 'rating-1')
216
+ })
217
+ })
218
+ })
@@ -0,0 +1,141 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { Spinner, spinnerVariants } from '../spinner'
5
+
6
+ describe('Spinner Component', () => {
7
+ describe('Rendering', () => {
8
+ it('renders correctly with default props', () => {
9
+ render(<Spinner data-testid="spinner" />)
10
+ const spinner = screen.getByTestId('spinner')
11
+ expect(spinner).toBeInTheDocument()
12
+ })
13
+
14
+ it('applies custom className', () => {
15
+ render(<Spinner className="custom-spinner" data-testid="spinner" />)
16
+ expect(screen.getByTestId('spinner')).toHaveClass('custom-spinner')
17
+ })
18
+
19
+ it('forwards ref correctly', () => {
20
+ const ref = React.createRef<HTMLDivElement>()
21
+ render(<Spinner ref={ref} />)
22
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
23
+ })
24
+
25
+ it('maintains displayName', () => {
26
+ expect(Spinner.displayName).toBe('Spinner')
27
+ })
28
+
29
+ it('passes through HTML attributes', () => {
30
+ render(<Spinner data-testid="spinner" id="loader-1" />)
31
+ expect(screen.getByTestId('spinner')).toHaveAttribute('id', 'loader-1')
32
+ })
33
+ })
34
+
35
+ describe('Accessibility', () => {
36
+ it('has role="status"', () => {
37
+ render(<Spinner />)
38
+ expect(screen.getByRole('status')).toBeInTheDocument()
39
+ })
40
+
41
+ it('has aria-live="polite"', () => {
42
+ render(<Spinner />)
43
+ expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
44
+ })
45
+
46
+ it('renders default accessible label "Loading" visually hidden', () => {
47
+ render(<Spinner />)
48
+ const label = screen.getByText('Loading')
49
+ expect(label).toBeInTheDocument()
50
+ expect(label).toHaveClass('sr-only')
51
+ })
52
+
53
+ it('contains the sr-only label inside the status region', () => {
54
+ render(<Spinner />)
55
+ const status = screen.getByRole('status')
56
+ expect(status).toContainElement(screen.getByText('Loading'))
57
+ })
58
+
59
+ it('renders a custom label', () => {
60
+ render(<Spinner label="Yükleniyor" />)
61
+ const label = screen.getByText('Yükleniyor')
62
+ expect(label).toHaveClass('sr-only')
63
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument()
64
+ })
65
+
66
+ it('marks the visual indicator as aria-hidden', () => {
67
+ render(<Spinner />)
68
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
69
+ expect(ring).toHaveAttribute('aria-hidden', 'true')
70
+ })
71
+ })
72
+
73
+ describe('Animation & reduced motion', () => {
74
+ it('applies animate-spin to the default ring', () => {
75
+ render(<Spinner />)
76
+ const ring = screen.getByRole('status').querySelector('span[aria-hidden="true"]')
77
+ expect(ring).toHaveClass('animate-spin')
78
+ })
79
+
80
+ it('disables animation under prefers-reduced-motion', () => {
81
+ render(<Spinner />)
82
+ const ring = screen.getByRole('status').querySelector('span[aria-hidden="true"]')
83
+ expect(ring).toHaveClass('motion-reduce:animate-none')
84
+ })
85
+ })
86
+
87
+ describe('Size variants', () => {
88
+ it('applies md size classes by default', () => {
89
+ render(<Spinner />)
90
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
91
+ expect(ring).toHaveClass('h-6', 'w-6')
92
+ })
93
+
94
+ it('applies sm size classes', () => {
95
+ render(<Spinner size="sm" />)
96
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
97
+ expect(ring).toHaveClass('h-4', 'w-4')
98
+ })
99
+
100
+ it('applies lg size classes', () => {
101
+ render(<Spinner size="lg" />)
102
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
103
+ expect(ring).toHaveClass('h-8', 'w-8')
104
+ })
105
+
106
+ it('applies xl size classes', () => {
107
+ render(<Spinner size="xl" />)
108
+ const ring = screen.getByRole('status').querySelector('.animate-spin')
109
+ expect(ring).toHaveClass('h-10', 'w-10')
110
+ })
111
+
112
+ it('spinnerVariants generates size classes directly', () => {
113
+ expect(spinnerVariants({ size: 'sm' })).toContain('h-4')
114
+ expect(spinnerVariants({ size: 'xl' })).toContain('h-10')
115
+ })
116
+ })
117
+
118
+ describe('Dots variant', () => {
119
+ it('renders three dots and no ring', () => {
120
+ render(<Spinner variant="dots" />)
121
+ const status = screen.getByRole('status')
122
+ expect(status.querySelector('.animate-spin')).not.toBeInTheDocument()
123
+ const dots = status.querySelectorAll('.animate-bounce')
124
+ expect(dots).toHaveLength(3)
125
+ })
126
+
127
+ it('keeps the sr-only label in dots variant', () => {
128
+ render(<Spinner variant="dots" label="Please wait" />)
129
+ const status = screen.getByRole('status')
130
+ const label = screen.getByText('Please wait')
131
+ expect(label).toHaveClass('sr-only')
132
+ expect(status).toContainElement(label)
133
+ })
134
+
135
+ it('disables dot animation under prefers-reduced-motion', () => {
136
+ render(<Spinner variant="dots" />)
137
+ const dot = screen.getByRole('status').querySelector('.animate-bounce')
138
+ expect(dot).toHaveClass('motion-reduce:animate-none')
139
+ })
140
+ })
141
+ })
@@ -0,0 +1,324 @@
1
+ "use client"
2
+
3
+ import * as React from "react";
4
+ import useEmblaCarousel, {
5
+ type UseEmblaCarouselType,
6
+ } from "embla-carousel-react";
7
+ import { cva, type VariantProps } from "class-variance-authority";
8
+ import { ArrowLeft, ArrowRight } from "lucide-react";
9
+
10
+ import { cn } from "../../lib/utils";
11
+ import { Button } from "./button";
12
+
13
+ /**
14
+ * Premium Carousel Component
15
+ *
16
+ * Embla Carousel tabanlı, erişilebilir ve esnek carousel bileşeni.
17
+ * Yatay/dikey yön desteği, klavye navigasyonu ve plugin (autoplay vb.) desteği sunar.
18
+ */
19
+
20
+ type CarouselApi = UseEmblaCarouselType[1];
21
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
22
+ type CarouselOptions = UseCarouselParameters[0];
23
+ type CarouselPlugin = UseCarouselParameters[1];
24
+
25
+ export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
26
+ /** Embla carousel seçenekleri (loop, align, axis vb.) */
27
+ opts?: CarouselOptions;
28
+ /** Embla plugin listesi (ör. autoplay) */
29
+ plugins?: CarouselPlugin;
30
+ /** Kaydırma yönü */
31
+ orientation?: "horizontal" | "vertical";
32
+ /** Embla API'sine dışarıdan erişmek için callback */
33
+ setApi?: (api: CarouselApi) => void;
34
+ }
35
+
36
+ interface CarouselContextProps
37
+ extends Pick<CarouselProps, "opts" | "plugins" | "setApi"> {
38
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0];
39
+ api: ReturnType<typeof useEmblaCarousel>[1];
40
+ scrollPrev: () => void;
41
+ scrollNext: () => void;
42
+ canScrollPrev: boolean;
43
+ canScrollNext: boolean;
44
+ orientation: "horizontal" | "vertical";
45
+ }
46
+
47
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null);
48
+
49
+ /**
50
+ * Carousel context hook'u — Carousel alt bileşenlerinin embla API'sine
51
+ * ve yön bilgisine erişmesini sağlar.
52
+ */
53
+ function useCarousel() {
54
+ const context = React.useContext(CarouselContext);
55
+
56
+ if (!context) {
57
+ throw new Error("useCarousel must be used within a <Carousel />");
58
+ }
59
+
60
+ return context;
61
+ }
62
+
63
+ /* -------------------------------------------------------------------------------------------------
64
+ * Carousel Root
65
+ * -----------------------------------------------------------------------------------------------*/
66
+ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
67
+ (
68
+ {
69
+ orientation = "horizontal",
70
+ opts,
71
+ setApi,
72
+ plugins,
73
+ className,
74
+ children,
75
+ ...props
76
+ },
77
+ ref
78
+ ) => {
79
+ // Yön bilgisi opts.axis ile de verilebilir — orientation prop'u öncelikli
80
+ const resolvedOrientation =
81
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal");
82
+
83
+ const [carouselRef, api] = useEmblaCarousel(
84
+ {
85
+ ...opts,
86
+ axis: resolvedOrientation === "horizontal" ? "x" : "y",
87
+ },
88
+ plugins
89
+ );
90
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
91
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
92
+
93
+ // Embla "select" olayında ileri/geri butonlarının durumunu güncelle
94
+ const onSelect = React.useCallback((emblaApi: CarouselApi) => {
95
+ if (!emblaApi) return;
96
+ setCanScrollPrev(emblaApi.canScrollPrev());
97
+ setCanScrollNext(emblaApi.canScrollNext());
98
+ }, []);
99
+
100
+ const scrollPrev = React.useCallback(() => {
101
+ api?.scrollPrev();
102
+ }, [api]);
103
+
104
+ const scrollNext = React.useCallback(() => {
105
+ api?.scrollNext();
106
+ }, [api]);
107
+
108
+ // Klavye navigasyonu: yatayda sol/sağ, dikeyde yukarı/aşağı ok tuşları
109
+ const handleKeyDown = React.useCallback(
110
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
111
+ const prevKey =
112
+ resolvedOrientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
113
+ const nextKey =
114
+ resolvedOrientation === "horizontal" ? "ArrowRight" : "ArrowDown";
115
+
116
+ if (event.key === prevKey) {
117
+ event.preventDefault();
118
+ scrollPrev();
119
+ } else if (event.key === nextKey) {
120
+ event.preventDefault();
121
+ scrollNext();
122
+ }
123
+ },
124
+ [resolvedOrientation, scrollPrev, scrollNext]
125
+ );
126
+
127
+ React.useEffect(() => {
128
+ if (!api || !setApi) return;
129
+ setApi(api);
130
+ }, [api, setApi]);
131
+
132
+ React.useEffect(() => {
133
+ if (!api) return;
134
+
135
+ onSelect(api);
136
+ api.on("reInit", onSelect);
137
+ api.on("select", onSelect);
138
+
139
+ return () => {
140
+ api.off("reInit", onSelect);
141
+ api.off("select", onSelect);
142
+ };
143
+ }, [api, onSelect]);
144
+
145
+ return (
146
+ <CarouselContext.Provider
147
+ value={{
148
+ carouselRef,
149
+ api,
150
+ opts,
151
+ scrollPrev,
152
+ scrollNext,
153
+ canScrollPrev,
154
+ canScrollNext,
155
+ orientation: resolvedOrientation,
156
+ }}
157
+ >
158
+ <div
159
+ ref={ref}
160
+ onKeyDownCapture={handleKeyDown}
161
+ className={cn("moonui-theme", "relative", className)}
162
+ role="region"
163
+ aria-roledescription="carousel"
164
+ {...props}
165
+ >
166
+ {children}
167
+ </div>
168
+ </CarouselContext.Provider>
169
+ );
170
+ }
171
+ );
172
+ Carousel.displayName = "Carousel";
173
+
174
+ /* -------------------------------------------------------------------------------------------------
175
+ * CarouselContent
176
+ * -----------------------------------------------------------------------------------------------*/
177
+ const carouselContentVariants = cva("flex", {
178
+ variants: {
179
+ orientation: {
180
+ horizontal: "-ml-4",
181
+ vertical: "-mt-4 flex-col",
182
+ },
183
+ },
184
+ defaultVariants: {
185
+ orientation: "horizontal",
186
+ },
187
+ });
188
+
189
+ export interface CarouselContentProps
190
+ extends React.HTMLAttributes<HTMLDivElement>,
191
+ Omit<VariantProps<typeof carouselContentVariants>, "orientation"> {}
192
+
193
+ const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>(
194
+ ({ className, ...props }, ref) => {
195
+ const { carouselRef, orientation } = useCarousel();
196
+
197
+ return (
198
+ <div ref={carouselRef} className="overflow-hidden">
199
+ <div
200
+ ref={ref}
201
+ className={cn(carouselContentVariants({ orientation }), className)}
202
+ {...props}
203
+ />
204
+ </div>
205
+ );
206
+ }
207
+ );
208
+ CarouselContent.displayName = "CarouselContent";
209
+
210
+ /* -------------------------------------------------------------------------------------------------
211
+ * CarouselItem
212
+ * -----------------------------------------------------------------------------------------------*/
213
+ const carouselItemVariants = cva("min-w-0 shrink-0 grow-0 basis-full", {
214
+ variants: {
215
+ orientation: {
216
+ horizontal: "pl-4",
217
+ vertical: "pt-4",
218
+ },
219
+ },
220
+ defaultVariants: {
221
+ orientation: "horizontal",
222
+ },
223
+ });
224
+
225
+ export interface CarouselItemProps
226
+ extends React.HTMLAttributes<HTMLDivElement>,
227
+ Omit<VariantProps<typeof carouselItemVariants>, "orientation"> {}
228
+
229
+ const CarouselItem = React.forwardRef<HTMLDivElement, CarouselItemProps>(
230
+ ({ className, ...props }, ref) => {
231
+ const { orientation } = useCarousel();
232
+
233
+ return (
234
+ <div
235
+ ref={ref}
236
+ role="group"
237
+ aria-roledescription="slide"
238
+ className={cn(carouselItemVariants({ orientation }), className)}
239
+ {...props}
240
+ />
241
+ );
242
+ }
243
+ );
244
+ CarouselItem.displayName = "CarouselItem";
245
+
246
+ /* -------------------------------------------------------------------------------------------------
247
+ * CarouselPrevious
248
+ * -----------------------------------------------------------------------------------------------*/
249
+ const CarouselPrevious = React.forwardRef<
250
+ HTMLButtonElement,
251
+ React.ComponentProps<typeof Button>
252
+ >(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
253
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
254
+
255
+ return (
256
+ <Button
257
+ ref={ref}
258
+ variant={variant}
259
+ size={size}
260
+ rounded="full"
261
+ className={cn(
262
+ "absolute",
263
+ orientation === "horizontal"
264
+ ? "-left-12 top-1/2 -translate-y-1/2"
265
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
266
+ className
267
+ )}
268
+ disabled={!canScrollPrev}
269
+ onClick={scrollPrev}
270
+ {...props}
271
+ >
272
+ <ArrowLeft className="h-4 w-4" aria-hidden="true" />
273
+ <span className="sr-only">Previous slide</span>
274
+ </Button>
275
+ );
276
+ });
277
+ CarouselPrevious.displayName = "CarouselPrevious";
278
+
279
+ /* -------------------------------------------------------------------------------------------------
280
+ * CarouselNext
281
+ * -----------------------------------------------------------------------------------------------*/
282
+ const CarouselNext = React.forwardRef<
283
+ HTMLButtonElement,
284
+ React.ComponentProps<typeof Button>
285
+ >(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
286
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
287
+
288
+ return (
289
+ <Button
290
+ ref={ref}
291
+ variant={variant}
292
+ size={size}
293
+ rounded="full"
294
+ className={cn(
295
+ "absolute",
296
+ orientation === "horizontal"
297
+ ? "-right-12 top-1/2 -translate-y-1/2"
298
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
299
+ className
300
+ )}
301
+ disabled={!canScrollNext}
302
+ onClick={scrollNext}
303
+ {...props}
304
+ >
305
+ <ArrowRight className="h-4 w-4" aria-hidden="true" />
306
+ <span className="sr-only">Next slide</span>
307
+ </Button>
308
+ );
309
+ });
310
+ CarouselNext.displayName = "CarouselNext";
311
+
312
+ export {
313
+ type CarouselApi,
314
+ type CarouselOptions,
315
+ type CarouselPlugin,
316
+ Carousel,
317
+ CarouselContent,
318
+ CarouselItem,
319
+ CarouselPrevious,
320
+ CarouselNext,
321
+ useCarousel,
322
+ carouselContentVariants,
323
+ carouselItemVariants,
324
+ };