at main 4.8 kB view raw
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}