@srcroot/ui 0.0.28 → 0.0.30
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.
- package/package.json +1 -1
- package/registry/carousel.tsx +82 -38
package/package.json
CHANGED
package/registry/carousel.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import { cn } from "@/lib/utils"
|
|
|
4
4
|
|
|
5
5
|
interface CarouselContextValue {
|
|
6
6
|
currentIndex: number
|
|
7
|
-
setCurrentIndex:
|
|
7
|
+
setCurrentIndex: React.Dispatch<React.SetStateAction<number>>
|
|
8
8
|
itemsCount: number
|
|
9
|
-
setItemsCount:
|
|
9
|
+
setItemsCount: React.Dispatch<React.SetStateAction<number>>
|
|
10
|
+
isTransitioning: boolean
|
|
11
|
+
setIsTransitioning: React.Dispatch<React.SetStateAction<boolean>>
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
|
|
@@ -14,44 +16,42 @@ const CarouselContext = React.createContext<CarouselContextValue | null>(null)
|
|
|
14
16
|
interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
15
17
|
/** Auto-play interval in ms (0 to disable) */
|
|
16
18
|
autoPlay?: number
|
|
17
|
-
/** Loop back to start */
|
|
19
|
+
/** Loop back to start (ignored in this infinite implementation as it's always true-ish, but kept for API) */
|
|
18
20
|
loop?: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
|
-
* Carousel/Slider component
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* <Carousel>
|
|
26
|
-
* <CarouselContent>
|
|
27
|
-
* <CarouselItem>Slide 1</CarouselItem>
|
|
28
|
-
* <CarouselItem>Slide 2</CarouselItem>
|
|
29
|
-
* </CarouselContent>
|
|
30
|
-
* <CarouselPrevious />
|
|
31
|
-
* <CarouselNext />
|
|
32
|
-
* </Carousel>
|
|
24
|
+
* Carousel/Slider component with Infinite Looping
|
|
33
25
|
*/
|
|
34
26
|
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
|
35
27
|
({ className, children, autoPlay = 0, loop = true, ...props }, ref) => {
|
|
36
|
-
|
|
28
|
+
// Start at 1 because 0 is the clone of the last item
|
|
29
|
+
const [currentIndex, setCurrentIndex] = React.useState(1)
|
|
37
30
|
const [itemsCount, setItemsCount] = React.useState(0)
|
|
31
|
+
const [isTransitioning, setIsTransitioning] = React.useState(true)
|
|
32
|
+
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
38
33
|
|
|
39
34
|
React.useEffect(() => {
|
|
40
|
-
if (autoPlay > 0 && itemsCount >
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return loop ? 0 : prev
|
|
45
|
-
}
|
|
46
|
-
return prev + 1
|
|
47
|
-
})
|
|
35
|
+
if (autoPlay > 0 && itemsCount > 1) {
|
|
36
|
+
intervalRef.current = setInterval(() => {
|
|
37
|
+
setIsTransitioning(true)
|
|
38
|
+
setCurrentIndex((prev) => prev + 1)
|
|
48
39
|
}, autoPlay)
|
|
49
|
-
return () =>
|
|
40
|
+
return () => {
|
|
41
|
+
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
42
|
+
}
|
|
50
43
|
}
|
|
51
|
-
}, [autoPlay, itemsCount
|
|
44
|
+
}, [autoPlay, itemsCount])
|
|
52
45
|
|
|
53
46
|
return (
|
|
54
|
-
<CarouselContext.Provider value={{
|
|
47
|
+
<CarouselContext.Provider value={{
|
|
48
|
+
currentIndex,
|
|
49
|
+
setCurrentIndex,
|
|
50
|
+
itemsCount,
|
|
51
|
+
setItemsCount,
|
|
52
|
+
isTransitioning,
|
|
53
|
+
setIsTransitioning
|
|
54
|
+
}}>
|
|
55
55
|
<div
|
|
56
56
|
ref={ref}
|
|
57
57
|
className={cn("relative", className)}
|
|
@@ -72,19 +72,59 @@ const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
|
|
|
72
72
|
const context = React.useContext(CarouselContext)
|
|
73
73
|
if (!context) throw new Error("CarouselContent must be used within Carousel")
|
|
74
74
|
|
|
75
|
-
const
|
|
75
|
+
const items = React.Children.toArray(children)
|
|
76
76
|
|
|
77
77
|
React.useEffect(() => {
|
|
78
|
-
context.setItemsCount(
|
|
79
|
-
}, [
|
|
78
|
+
context.setItemsCount(items.length)
|
|
79
|
+
}, [items.length, context])
|
|
80
|
+
|
|
81
|
+
// Clone first and last items for infinite loop illusion
|
|
82
|
+
const firstClone = items.length > 0 && React.isValidElement(items[0])
|
|
83
|
+
? React.cloneElement(items[0] as React.ReactElement, { key: "clone-first" })
|
|
84
|
+
: null
|
|
85
|
+
const lastClone = items.length > 0 && React.isValidElement(items[items.length - 1])
|
|
86
|
+
? React.cloneElement(items[items.length - 1] as React.ReactElement, { key: "clone-last" })
|
|
87
|
+
: null
|
|
88
|
+
|
|
89
|
+
// If we have items, prepend last-clone and append first-clone
|
|
90
|
+
const displayItems = items.length > 1 ? [lastClone, ...items, firstClone] : items
|
|
91
|
+
|
|
92
|
+
const handleTransitionEnd = () => {
|
|
93
|
+
if (items.length <= 1) return
|
|
94
|
+
|
|
95
|
+
// If reached the end clone (index = N + 1), snap back to first real item (index = 1)
|
|
96
|
+
if (context.currentIndex >= items.length + 1) {
|
|
97
|
+
context.setIsTransitioning(false)
|
|
98
|
+
context.setCurrentIndex(1)
|
|
99
|
+
}
|
|
100
|
+
// If reached the start clone (index = 0), snap forward to last real item (index = N)
|
|
101
|
+
else if (context.currentIndex <= 0) {
|
|
102
|
+
context.setIsTransitioning(false)
|
|
103
|
+
context.setCurrentIndex(items.length)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Re-enable transition after a snap (on next frame)
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (!context.isTransitioning) {
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
context.setIsTransitioning(true)
|
|
112
|
+
}, 50)
|
|
113
|
+
return () => clearTimeout(timer)
|
|
114
|
+
}
|
|
115
|
+
}, [context.isTransitioning, context])
|
|
80
116
|
|
|
81
117
|
return (
|
|
82
118
|
<div ref={ref} className={cn("overflow-hidden", className)} {...props}>
|
|
83
119
|
<div
|
|
84
|
-
className="
|
|
85
|
-
style={{
|
|
120
|
+
className="flex h-full w-full"
|
|
121
|
+
style={{
|
|
122
|
+
transform: `translateX(-${context.currentIndex * 100}%)`,
|
|
123
|
+
transition: context.isTransitioning ? 'transform 300ms ease-in-out' : 'none'
|
|
124
|
+
}}
|
|
125
|
+
onTransitionEnd={handleTransitionEnd}
|
|
86
126
|
>
|
|
87
|
-
{
|
|
127
|
+
{displayItems}
|
|
88
128
|
</div>
|
|
89
129
|
</div>
|
|
90
130
|
)
|
|
@@ -112,7 +152,10 @@ const CarouselPrevious = React.forwardRef<
|
|
|
112
152
|
const context = React.useContext(CarouselContext)
|
|
113
153
|
if (!context) throw new Error("CarouselPrevious must be used within Carousel")
|
|
114
154
|
|
|
115
|
-
const
|
|
155
|
+
const handlePrev = () => {
|
|
156
|
+
context.setIsTransitioning(true)
|
|
157
|
+
context.setCurrentIndex((prev) => prev - 1)
|
|
158
|
+
}
|
|
116
159
|
|
|
117
160
|
return (
|
|
118
161
|
<button
|
|
@@ -123,8 +166,7 @@ const CarouselPrevious = React.forwardRef<
|
|
|
123
166
|
"hover:bg-accent disabled:opacity-50",
|
|
124
167
|
className
|
|
125
168
|
)}
|
|
126
|
-
|
|
127
|
-
onClick={() => context.setCurrentIndex(context.currentIndex - 1)}
|
|
169
|
+
onClick={handlePrev}
|
|
128
170
|
aria-label="Previous slide"
|
|
129
171
|
{...props}
|
|
130
172
|
>
|
|
@@ -143,7 +185,10 @@ const CarouselNext = React.forwardRef<
|
|
|
143
185
|
const context = React.useContext(CarouselContext)
|
|
144
186
|
if (!context) throw new Error("CarouselNext must be used within Carousel")
|
|
145
187
|
|
|
146
|
-
const
|
|
188
|
+
const handleNext = () => {
|
|
189
|
+
context.setIsTransitioning(true)
|
|
190
|
+
context.setCurrentIndex((prev) => prev + 1)
|
|
191
|
+
}
|
|
147
192
|
|
|
148
193
|
return (
|
|
149
194
|
<button
|
|
@@ -154,8 +199,7 @@ const CarouselNext = React.forwardRef<
|
|
|
154
199
|
"hover:bg-accent disabled:opacity-50",
|
|
155
200
|
className
|
|
156
201
|
)}
|
|
157
|
-
|
|
158
|
-
onClick={() => context.setCurrentIndex(context.currentIndex + 1)}
|
|
202
|
+
onClick={handleNext}
|
|
159
203
|
aria-label="Next slide"
|
|
160
204
|
{...props}
|
|
161
205
|
>
|