Skip to content

Commit dae4298

Browse files
committed
add carousel
1 parent 23d8239 commit dae4298

File tree

11 files changed

+432
-24
lines changed

11 files changed

+432
-24
lines changed

app/(site)/projects/ProjectsStatic.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type StaticProject = {
2323
isExternal: boolean
2424
}
2525

26-
const STATIC_PROJECTS: StaticProject[] = [
26+
export const STATIC_PROJECTS: StaticProject[] = [
2727
{
2828
id: "react-zero-ui",
2929
src: zeroPreview,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Children } from "react"
2+
import { LazyCarouselButtons } from "./LazyButtons"
3+
4+
type CarouselProps = {
5+
children: React.ReactNode
6+
/** Number of slides to show on desktop (≥ 1024px) */
7+
slidesToShow: number
8+
/** Number of slides to show on desktop (≥ 1280px) */
9+
xlSlidesToShow: number
10+
/** Number of slides to show on tablet (576px - 1024px) */
11+
tabletSlidesToShow?: number
12+
/** Number of slides to show on mobile (≤ 576px) */
13+
mobileSlidesToShow?: number
14+
/** Gap between cards in px */
15+
gap?: number
16+
/** Auto play interval in ms */
17+
autoplay?: number
18+
/** Set an Active State on Cards, if Active State>CurrentIndex, Active State will move up before the carousel Slides */
19+
activeState?: boolean
20+
21+
className?: string
22+
}
23+
24+
export const ZeroUICarousel: React.FC<CarouselProps> = ({
25+
children,
26+
xlSlidesToShow,
27+
slidesToShow,
28+
tabletSlidesToShow = slidesToShow,
29+
mobileSlidesToShow = tabletSlidesToShow,
30+
gap = 0,
31+
className,
32+
activeState = false,
33+
autoplay,
34+
}) => {
35+
const total = Children.count(children)
36+
37+
return (
38+
// ZeroUICarousel.tsx (Server)
39+
<section className={"sm:[--gap:10px] lg:[--gap:20px] " + className}>
40+
<div
41+
data-carousel-root
42+
style={
43+
{
44+
"--gap": `${gap}px`,
45+
"--xlvis": xlSlidesToShow,
46+
"--dvis": slidesToShow,
47+
"--tvis": tabletSlidesToShow,
48+
"--mvis": mobileSlidesToShow,
49+
} as React.CSSProperties
50+
}
51+
className="carousel-container relative w-full"
52+
>
53+
<div
54+
data-carousel-track
55+
data-active="1" // 1-based active index for CSS targeting
56+
style={{ "--active": 1 } as React.CSSProperties} // 1-based active
57+
className={
58+
"carousel-track flex translate-x-[calc(var(--carousel-idx,0)*(calc((100%-(var(--vis)-1)*var(--gap))/var(--vis)+var(--gap)))*-1)] " +
59+
" gap-[var(--gap)] transition-transform duration-300 ease-in-out will-change-transform [--vis:var(--mvis)] md:[--vis:var(--tvis)] lg:[--vis:var(--dvis)] 2xl:[--vis:var(--xlvis)]"
60+
}
61+
>
62+
{Children.map(children, (child, i) => (
63+
<div
64+
key={i}
65+
// for the initial state
66+
data-active={i === 0 && "true"}
67+
data-i={i + 1} // 1-based index per slide
68+
className="group flex-[0_0_calc((100%-(var(--vis)-1)*var(--gap))/var(--vis))] transition-all duration-300 ease-in-out"
69+
>
70+
{child}
71+
</div>
72+
))}
73+
</div>
74+
{/* Buttons remain the ONLY client part */}
75+
<LazyCarouselButtons total={total} desktopVis={slidesToShow} autoPlayInterval={autoplay} activeState={activeState} />
76+
</div>
77+
</section>
78+
)
79+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"use client"
2+
3+
import { useEffect, useRef } from "react"
4+
import { ArrowRight } from "@react-zero-ui/icon-sprite"
5+
6+
type Props = {
7+
total: number
8+
desktopVis: number
9+
autoPlayInterval?: number | undefined
10+
activeState: boolean
11+
}
12+
13+
const btn =
14+
"bg-white/80 border-2 border-gray-300 animate-click absolute z-1 rounded-full p-1.5 backdrop-blur-sm transition-all duration-300 hover:scale-110 translate-y-20 bubble-hover active before:opacity-20 "
15+
16+
export function CarouselButtons({ total, desktopVis, autoPlayInterval, activeState }: Props) {
17+
// ---------- refs & cached DOM ----------
18+
const prevBtnRef = useRef<HTMLButtonElement>(null)
19+
const rootRef = useRef<HTMLElement | null>(null)
20+
const trackRef = useRef<HTMLElement | null>(null)
21+
const itemsRef = useRef<HTMLElement[]>([])
22+
const currentActiveElRef = useRef<HTMLElement | null>(null)
23+
24+
// autoplay lifecycle
25+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
26+
const pausedRef = useRef(false)
27+
28+
// IO for offscreen pause
29+
const ioRef = useRef<IntersectionObserver | null>(null)
30+
// RO for layout/viewport changes
31+
32+
// ---------- helpers (no DOM queries; only use cached refs) ----------
33+
const readState = () => {
34+
const t = trackRef.current
35+
if (!t) {
36+
return { vis: desktopVis, start: 0, active: 1 }
37+
}
38+
const cs = getComputedStyle(t)
39+
const vis = Number(cs.getPropertyValue("--vis")) || desktopVis
40+
const start = Number(cs.getPropertyValue("--carousel-idx").trim() || "0") || 0 // 0-based
41+
const active = Number(cs.getPropertyValue("--active").trim() || t.dataset.active || "1") || 1 // 1-based
42+
return { vis, start, active }
43+
}
44+
45+
const writeStart = (s: number) => {
46+
const t = trackRef.current
47+
if (!t) return
48+
const { vis } = readState()
49+
const max = Math.max(0, total - vis)
50+
const clamped = Math.max(0, Math.min(s, max))
51+
// dedup
52+
const current = Number(getComputedStyle(t).getPropertyValue("--carousel-idx").trim() || "0") || 0
53+
if (clamped !== current) t.style.setProperty("--carousel-idx", String(clamped))
54+
}
55+
56+
const setActive = (target: number) => {
57+
const t = trackRef.current
58+
if (!t) return
59+
60+
// dedup: skip if already active
61+
const cur = Number(t.dataset.active || "1")
62+
if (cur === target) return
63+
64+
// toggle DOM data-active using cached nodes
65+
const prevEl = currentActiveElRef.current || t.querySelector<HTMLElement>('[data-active="true"]')
66+
if (prevEl) prevEl.setAttribute("data-active", "false")
67+
68+
const idx = target - 1 // data-i is 1-based in your markup
69+
const nextEl = itemsRef.current[idx] || t.querySelector<HTMLElement>(`[data-i="${target}"]`)
70+
if (nextEl) {
71+
nextEl.setAttribute("data-active", "true")
72+
currentActiveElRef.current = nextEl
73+
}
74+
75+
// reflect track-level state
76+
t.dataset.active = String(target)
77+
t.style.setProperty("--active", String(target))
78+
79+
// keep active in view using current vis/start snapshot (reads once)
80+
const { vis, start } = readState()
81+
const first = start + 1
82+
const last = start + vis
83+
if (target > last) writeStart(target - vis)
84+
else if (target < first) writeStart(target - 1)
85+
}
86+
87+
const next = () => {
88+
if (activeState) {
89+
// Active state mode: change active state, slide only if needed
90+
const current = readState().active
91+
const nextSlide = current >= total ? 1 : current + 1
92+
setActive(nextSlide)
93+
} else {
94+
// Normal mode: always slide the carousel
95+
const { start, vis } = readState()
96+
const maxStart = Math.max(0, total - vis)
97+
const nextStart = start >= maxStart ? 0 : start + 1
98+
writeStart(nextStart)
99+
}
100+
}
101+
const prev = () => {
102+
if (activeState) {
103+
// Active state mode: change active state, slide only if needed
104+
const current = readState().active
105+
const prevSlide = current <= 1 ? total : current - 1
106+
setActive(prevSlide)
107+
} else {
108+
// Normal mode: always slide the carousel
109+
const { start, vis } = readState()
110+
const maxStart = Math.max(0, total - vis)
111+
const prevStart = start <= 0 ? maxStart : start - 1
112+
writeStart(prevStart)
113+
}
114+
}
115+
116+
// ---------- autoplay ----------
117+
const startAutoplay = () => {
118+
if (!autoPlayInterval || pausedRef.current || intervalRef.current) return
119+
intervalRef.current = setInterval(() => {
120+
if (!pausedRef.current) next()
121+
}, autoPlayInterval)
122+
}
123+
const stopAutoplay = () => {
124+
if (intervalRef.current) {
125+
clearInterval(intervalRef.current)
126+
intervalRef.current = null
127+
}
128+
}
129+
const resetAutoplay = () => {
130+
stopAutoplay()
131+
startAutoplay()
132+
}
133+
134+
// ---------- mount: cache DOM once ----------
135+
useEffect(() => {
136+
const root = prevBtnRef.current?.closest<HTMLElement>("[data-carousel-root]") || null
137+
const track = root?.querySelector<HTMLElement>("[data-carousel-track]") || null
138+
rootRef.current = root
139+
trackRef.current = track || null
140+
141+
if (track) {
142+
// cache item nodes once
143+
itemsRef.current = Array.from(track.querySelectorAll<HTMLElement>("[data-i]"))
144+
// seed current active
145+
const activeIdx = Number(track.dataset.active || "1") - 1
146+
currentActiveElRef.current = itemsRef.current[activeIdx] || null
147+
}
148+
149+
// initial clamp (if CSS vis/start mismatch)
150+
const { vis, start, active } = readState()
151+
const max = Math.max(0, total - vis)
152+
if (start > max) writeStart(max)
153+
setActive(active) // also ensures correct data-active node
154+
155+
return () => {
156+
itemsRef.current = []
157+
currentActiveElRef.current = null
158+
rootRef.current = null
159+
trackRef.current = null
160+
}
161+
// eslint-disable-next-line react-hooks/exhaustive-deps
162+
}, [total])
163+
164+
// ---------- autoplay lifecycle + pause conditions ----------
165+
useEffect(() => {
166+
// respect reduced motion
167+
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
168+
pausedRef.current = pausedRef.current || prefersReduced
169+
170+
// pause when offscreen
171+
const track = trackRef.current
172+
if (track && !ioRef.current) {
173+
ioRef.current = new IntersectionObserver(
174+
([e]) => {
175+
pausedRef.current = !e.isIntersecting
176+
if (!pausedRef.current) startAutoplay()
177+
},
178+
{ root: null, threshold: 0 }
179+
)
180+
ioRef.current.observe(track)
181+
}
182+
183+
// pause on tab hide/show
184+
const onVis = () => {
185+
pausedRef.current = document.visibilityState !== "visible"
186+
if (!pausedRef.current) startAutoplay()
187+
}
188+
document.addEventListener("visibilitychange", onVis, { passive: true })
189+
190+
// // pause on hover/focus within root
191+
const r = rootRef.current
192+
const onEnter = () => {
193+
pausedRef.current = true
194+
stopAutoplay()
195+
}
196+
const onLeave = () => {
197+
pausedRef.current = false
198+
startAutoplay()
199+
}
200+
r?.addEventListener("pointerenter", onEnter, { passive: true })
201+
r?.addEventListener("pointerleave", onLeave, { passive: true })
202+
r?.addEventListener("focusin", onEnter)
203+
r?.addEventListener("focusout", onLeave)
204+
205+
// kick it off
206+
startAutoplay()
207+
208+
return () => {
209+
stopAutoplay()
210+
document.removeEventListener("visibilitychange", onVis)
211+
r?.removeEventListener("pointerenter", onEnter)
212+
r?.removeEventListener("pointerleave", onLeave)
213+
r?.removeEventListener("focusin", onEnter)
214+
r?.removeEventListener("focusout", onLeave)
215+
ioRef.current?.disconnect()
216+
ioRef.current = null
217+
}
218+
// eslint-disable-next-line react-hooks/exhaustive-deps
219+
}, [autoPlayInterval])
220+
221+
// ---------- react to layout/viewport changes (vis may change) ----------
222+
useEffect(() => {
223+
const onResize = () => {
224+
setActive(1)
225+
}
226+
const mediaQuery = window.matchMedia("(min-width: 576px) and (max-width: 1024px)")
227+
mediaQuery.addEventListener("change", onResize)
228+
return () => mediaQuery.removeEventListener("change", onResize)
229+
}, [total])
230+
231+
return (
232+
<>
233+
<button
234+
ref={prevBtnRef}
235+
type="button"
236+
onClick={() => {
237+
prev()
238+
resetAutoplay()
239+
}}
240+
aria-label="Previous"
241+
className={btn + " bottom-8 left-4"}
242+
>
243+
<ArrowRight strokeWidth={2.5} size={24} className="rotate-180" />
244+
</button>
245+
<button
246+
type="button"
247+
onClick={() => {
248+
next()
249+
resetAutoplay()
250+
}}
251+
aria-label="Next"
252+
className={btn + " right-4 bottom-8"}
253+
>
254+
<ArrowRight strokeWidth={2.5} size={24} />
255+
</button>
256+
</>
257+
)
258+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"use client"
2+
import dynamic from "next/dynamic"
3+
4+
export const LazyCarouselButtons = /*PURE */ dynamic(() => import("./CarouselButtons").then((mod) => mod.CarouselButtons), {
5+
ssr: false,
6+
})

0 commit comments

Comments
 (0)