|
1 | | -import { useState, useEffect } from 'react' |
| 1 | +import { useState, useEffect, useRef } from 'react' |
2 | 2 | import { ChevronLeftIcon, ChevronRightIcon } from '@primer/octicons-react' |
3 | 3 | import { Token } from '@primer/react' |
4 | 4 | import cx from 'classnames' |
@@ -53,15 +53,27 @@ export const LandingCarousel = ({ |
53 | 53 | recommended, |
54 | 54 | }: LandingCarouselProps) => { |
55 | 55 | const [currentPage, setCurrentPage] = useState(0) |
| 56 | + const [isAnimating, setIsAnimating] = useState(false) |
56 | 57 | const itemsPerView = useResponsiveItemsPerView() |
57 | 58 | const { t } = useTranslation('discovery_landing') |
58 | 59 | const headingText = heading || t('recommended') |
| 60 | + // Ref to store timeout IDs for cleanup |
| 61 | + const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null) |
59 | 62 |
|
60 | 63 | // Reset to first page when itemsPerView changes (screen size changes) |
61 | 64 | useEffect(() => { |
62 | 65 | setCurrentPage(0) |
63 | 66 | }, [itemsPerView]) |
64 | 67 |
|
| 68 | + // Cleanup timeout on unmount |
| 69 | + useEffect(() => { |
| 70 | + return () => { |
| 71 | + if (animationTimeoutRef.current) { |
| 72 | + clearTimeout(animationTimeoutRef.current) |
| 73 | + } |
| 74 | + } |
| 75 | + }, []) |
| 76 | + |
65 | 77 | // Helper function to find article data from tocItems |
66 | 78 | const findArticleData = (articlePath: string) => { |
67 | 79 | if (typeof articlePath !== 'string') { |
@@ -94,11 +106,41 @@ export const LandingCarousel = ({ |
94 | 106 | const totalPages = Math.ceil(totalItems / itemsPerView) |
95 | 107 |
|
96 | 108 | const goToPrevious = () => { |
| 109 | + if (currentPage === 0 || isAnimating) return |
| 110 | + |
| 111 | + // Clear any existing timeout |
| 112 | + if (animationTimeoutRef.current) { |
| 113 | + clearTimeout(animationTimeoutRef.current) |
| 114 | + } |
| 115 | + |
| 116 | + setIsAnimating(true) |
97 | 117 | setCurrentPage((prev) => Math.max(0, prev - 1)) |
| 118 | + |
| 119 | + // Set animation state to false after transition completes |
| 120 | + // Duration matches CSS custom property --carousel-transition-duration (300ms) |
| 121 | + animationTimeoutRef.current = setTimeout(() => { |
| 122 | + setIsAnimating(false) |
| 123 | + animationTimeoutRef.current = null |
| 124 | + }, 300) |
98 | 125 | } |
99 | 126 |
|
100 | 127 | const goToNext = () => { |
| 128 | + if (currentPage >= totalPages - 1 || isAnimating) return |
| 129 | + |
| 130 | + // Clear any existing timeout |
| 131 | + if (animationTimeoutRef.current) { |
| 132 | + clearTimeout(animationTimeoutRef.current) |
| 133 | + } |
| 134 | + |
| 135 | + setIsAnimating(true) |
101 | 136 | setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)) |
| 137 | + |
| 138 | + // Set animation state to false after transition completes |
| 139 | + // Duration matches CSS custom property --carousel-transition-duration (300ms) |
| 140 | + animationTimeoutRef.current = setTimeout(() => { |
| 141 | + setIsAnimating(false) |
| 142 | + animationTimeoutRef.current = null |
| 143 | + }, 300) |
102 | 144 | } |
103 | 145 |
|
104 | 146 | // Calculate the start index based on current page |
@@ -136,7 +178,10 @@ export const LandingCarousel = ({ |
136 | 178 | )} |
137 | 179 | </div> |
138 | 180 |
|
139 | | - <div className={styles.itemsGrid} data-testid="carousel-items"> |
| 181 | + <div |
| 182 | + className={cx(styles.itemsGrid, { [styles.animating]: isAnimating })} |
| 183 | + data-testid="carousel-items" |
| 184 | + > |
140 | 185 | {visibleItems.map((article: ProcessedArticleItem, index) => ( |
141 | 186 | <div |
142 | 187 | key={startIndex + index} |
|
0 commit comments