Skip to content

Commit 3b2e7b5

Browse files
feat(06-01): add swipeable carousel with embla-carousel-react
- Install embla-carousel-react v8.6.0 - Create MobileCarousel component with Instagram-style swipe - Implement dot indicators tracking selected slide via select event - Use loop: false for 2-slide carousel (critical: loop breaks with only 2 slides) - Add touch-pan-y for vertical scroll compatibility - Support aria labels for accessibility NAV-01: Horizontal swipe gesture for navigation NAV-02: Dot indicators track current slide position NAV-03: Default to heatmap view (slide 0)
1 parent a20dc5d commit 3b2e7b5

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"autoprefixer": "^10.4.23",
3131
"dexie": "^4.2.1",
3232
"dexie-react-hooks": "^4.2.0",
33+
"embla-carousel-react": "^8.6.0",
3334
"lucide-react": "^0.562.0",
3435
"postcss": "^8.5.6",
3536
"react": "^19.2.3",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Mobile Carousel Component
3+
*
4+
* Swipeable carousel container for horizontal navigation between
5+
* MobileHeatmap (slide 1) and MobileMuscleList (slide 2).
6+
*
7+
* Pattern: Instagram-style horizontal swipe with dot indicators
8+
* Library: embla-carousel-react for physics-based swipe handling
9+
*/
10+
11+
import { useState, useEffect, useCallback } from 'react';
12+
import useEmblaCarousel from 'embla-carousel-react';
13+
import { MobileHeatmap } from './MobileHeatmap';
14+
import { MobileMuscleList } from './MobileMuscleList';
15+
16+
interface MobileCarouselProps {
17+
profileId: string | null;
18+
daysBack?: number;
19+
}
20+
21+
/**
22+
* Mobile carousel with swipeable navigation between heatmap and muscle list.
23+
* NAV-01: Horizontal swipe gesture for navigation
24+
* NAV-02: Dot indicators track current slide position
25+
* NAV-03: Default to heatmap view (slide 0)
26+
*/
27+
export function MobileCarousel({
28+
profileId,
29+
daysBack = 7
30+
}: MobileCarouselProps): React.ReactElement {
31+
// Initialize Embla with critical options
32+
const [emblaRef, emblaApi] = useEmblaCarousel({
33+
loop: false, // Critical: loop breaks with only 2 slides
34+
dragFree: false, // Snap to slides (no free-drag)
35+
startIndex: 0, // NAV-03: Default to heatmap
36+
});
37+
38+
// Track selected slide index for dot indicators
39+
const [selectedIndex, setSelectedIndex] = useState(0);
40+
41+
// Update selected index when slide changes
42+
const onSelect = useCallback(() => {
43+
if (!emblaApi) return;
44+
setSelectedIndex(emblaApi.selectedScrollSnap());
45+
}, [emblaApi]);
46+
47+
// Subscribe to Embla's select event
48+
useEffect(() => {
49+
if (!emblaApi) return;
50+
51+
emblaApi.on('select', onSelect);
52+
onSelect(); // Set initial state
53+
54+
return (): void => {
55+
emblaApi.off('select', onSelect);
56+
};
57+
}, [emblaApi, onSelect]);
58+
59+
// Programmatic scroll to slide
60+
const scrollTo = useCallback((index: number): void => {
61+
emblaApi?.scrollTo(index);
62+
}, [emblaApi]);
63+
64+
return (
65+
<div>
66+
{/* Carousel viewport */}
67+
<div className="overflow-hidden" ref={emblaRef}>
68+
<div className="flex touch-pan-y">
69+
{/* Slide 1: Body Heatmap (NAV-03: default view) */}
70+
<div className="flex-[0_0_100%] min-w-0 px-4">
71+
<MobileHeatmap profileId={profileId} daysBack={daysBack} />
72+
</div>
73+
74+
{/* Slide 2: Muscle List */}
75+
<div className="flex-[0_0_100%] min-w-0 px-4">
76+
<MobileMuscleList profileId={profileId} daysBack={daysBack} />
77+
</div>
78+
</div>
79+
</div>
80+
81+
{/* Dot indicators (NAV-02) */}
82+
<div
83+
className="flex justify-center gap-2 py-4"
84+
role="tablist"
85+
aria-label="Carousel navigation"
86+
>
87+
{[0, 1].map((index) => (
88+
<button
89+
key={index}
90+
onClick={() => scrollTo(index)}
91+
className={`
92+
h-2 rounded-full transition-all duration-200
93+
${index === selectedIndex
94+
? 'w-4 bg-amber-500'
95+
: 'w-2 bg-primary-600 active:bg-primary-500'
96+
}
97+
`}
98+
role="tab"
99+
aria-selected={index === selectedIndex}
100+
aria-label={index === 0 ? 'Body heatmap view' : 'Muscle list view'}
101+
/>
102+
))}
103+
</div>
104+
</div>
105+
);
106+
}

0 commit comments

Comments
 (0)