My personal website
1import { ChevronLeft, ChevronRight } from 'lucide-react';
2import { useEffect, useRef, useState } from 'react';
3// Components
4import { Button } from '../Button/Button';
5import { CardArticle } from '../CardArticle/CardArticle';
6import { Heading } from '../Heading/Heading';
7import { Paragraph } from '../Paragraph/Paragraph';
8// Styles & Types
9import * as styles from './PostCarousel.styles';
10import { PostCarouselProps } from './PostCarousel.types';
11
12export default function PostCarousel({ posts, title = 'Latest Posts' }: PostCarouselProps) {
13 const [currentIndex, setCurrentIndex] = useState(0);
14 const [visibleCount, setVisibleCount] = useState(3);
15 const containerRef = useRef<HTMLDivElement>(null);
16 const carouselRef = useRef<HTMLDivElement>(null);
17 const [announcement, setAnnouncement] = useState('');
18
19 // Resize Logic (Detect How Many Cards to Show)
20 useEffect(() => {
21 const updateVisibleCount = () => {
22 if (!containerRef.current) return;
23 const width = containerRef.current.offsetWidth;
24 let newCount = width < 640 ? 1 : width < 1024 ? 2 : 3;
25 setVisibleCount(newCount);
26 setCurrentIndex((prevIndex) => Math.min(prevIndex, Math.max(0, posts.length - newCount)));
27 };
28
29 let resizeTimer: ReturnType<typeof setTimeout>;
30 const handleResize = () => {
31 clearTimeout(resizeTimer);
32 resizeTimer = setTimeout(updateVisibleCount, 100);
33 };
34
35 updateVisibleCount();
36 window.addEventListener('resize', handleResize);
37 return () => {
38 window.removeEventListener('resize', handleResize);
39 clearTimeout(resizeTimer);
40 };
41 }, [posts.length]);
42
43 // Navigation Logic
44 const goToPrevious = () => {
45 if (currentIndex === 0) return;
46 setCurrentIndex((prevIndex) => Math.max(0, prevIndex - visibleCount));
47 };
48
49 const goToNext = () => {
50 if (currentIndex + visibleCount >= posts.length) return;
51 setCurrentIndex((prevIndex) => Math.min(posts.length - visibleCount, prevIndex + visibleCount));
52 };
53
54 // Announce Navigation for Screen Readers
55 useEffect(() => {
56 setAnnouncement(
57 `Showing posts ${currentIndex + 1} to ${Math.min(currentIndex + visibleCount, posts.length)} of ${posts.length}`,
58 );
59 }, [
60 currentIndex,
61 visibleCount,
62 posts.length,
63 ]);
64
65 return (
66 <section
67 className={styles.carouselContainer}
68 aria-labelledby="carousel-heading"
69 role="region"
70 aria-roledescription="carousel"
71 >
72 {title && (
73 <Heading level={2} className={styles.carouselTitle}>
74 {title}
75 </Heading>
76 )}
77
78 {/* Screen reader announcement */}
79 <div className="sr-only" aria-live="polite" aria-atomic="true">
80 {announcement}
81 </div>
82
83 {/* Main Carousel Container */}
84 <div className={styles.carouselWrapper} ref={containerRef}>
85 <div className="relative mx-[-5%] px-[5%]">
86 <div
87 ref={carouselRef}
88 className={styles.carouselTrack}
89 style={{
90 transform: `translateX(calc(-${currentIndex * (100 / visibleCount)}% + ${currentIndex > 0 ? '5%' : '0%'}))`,
91 }}
92 aria-live="polite"
93 >
94 {posts.map((post) => (
95 <div
96 key={post.key}
97 className={`px-2 flex-shrink-0 transition-all duration-300`}
98 style={{ width: `${100 / visibleCount}%` }}
99 aria-hidden={post.key < currentIndex || post.key >= currentIndex + visibleCount} // ✅ post.key is now correctly used as a number
100 >
101 <CardArticle
102 article={{
103 title: post.title,
104 subtitle: post.summary,
105 coverImage: post.coverImage,
106 articleUrl: post.canonicalURL,
107 publication: post.affiliationName,
108 published: post.published,
109 }}
110 />
111 </div>
112 ))}
113 </div>
114 </div>
115 </div>
116
117 {/* Navigation Controls */}
118 <div className={styles.navigationWrapper}>
119 <Paragraph className="text-sm text-muted-foreground">
120 Showing {currentIndex + 1}-{Math.min(currentIndex + visibleCount, posts.length)} of {posts.length} posts
121 </Paragraph>
122 <div className={styles.buttonGroup} role="group" aria-label="Carousel navigation">
123 <Button className={styles.navButton} onClick={goToPrevious} disabled={currentIndex === 0}>
124 <ChevronLeft className="h-4 w-4" />
125 </Button>
126 <Button
127 className={styles.navButton}
128 onClick={goToNext}
129 disabled={currentIndex + visibleCount >= posts.length}
130 >
131 <ChevronRight className="h-4 w-4" />
132 </Button>
133 </div>
134 </div>
135 </section>
136 );
137}