1- import type { JSX , ReactNode } from 'react' ;
2- import { ChevronLeftIcon , ChevronRightIcon , ArrowRightIcon } from '@heroicons/react/24/outline' ;
1+ import { useEffect , useRef , useState , type JSX , type ReactNode } from 'react' ;
2+ import { ChevronLeftIcon , ChevronRightIcon , ArrowRightIcon , ArrowPathIcon } from '@heroicons/react/24/outline' ;
33import clsx from 'clsx' ;
44import type { CardChainItem , CardChainProps } from './CardChain.types' ;
55
@@ -148,28 +148,40 @@ function NavArrow({
148148 direction,
149149 onClick,
150150 disabled,
151+ loading,
152+ badgeCount,
151153 title,
152154} : {
153155 direction : 'left' | 'right' ;
154156 onClick ?: ( ) => void ;
155157 disabled : boolean ;
158+ loading ?: boolean ;
159+ badgeCount ?: number ;
156160 title : string ;
157161} ) : JSX . Element {
158162 const Icon = direction === 'left' ? ChevronLeftIcon : ChevronRightIcon ;
163+ const showBadge = badgeCount !== undefined && badgeCount > 0 && ! loading ;
159164
160165 return (
161166 < button
162167 onClick = { onClick }
163- disabled = { disabled }
168+ disabled = { disabled || loading }
164169 className = { clsx (
165- 'z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 transition-all' ,
166- ! disabled
167- ? 'border-border bg-surface text-muted hover:border-primary hover:bg-primary/10 hover:text-primary'
168- : 'cursor-not-allowed border-border/50 bg-surface/50 text-muted/30'
170+ 'relative z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 transition-all' ,
171+ loading
172+ ? 'border-primary/50 bg-primary/5 text-primary'
173+ : ! disabled
174+ ? 'border-border bg-surface text-muted hover:border-primary hover:bg-primary/10 hover:text-primary'
175+ : 'cursor-not-allowed border-border/50 bg-surface/50 text-muted/30'
169176 ) }
170177 title = { title }
171178 >
172- < Icon className = "size-5" />
179+ { loading ? < ArrowPathIcon className = "size-5 animate-spin" /> : < Icon className = "size-5" /> }
180+ { showBadge && (
181+ < span className = "absolute -top-2 -right-2 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white" >
182+ { badgeCount > 99 ? '99+' : badgeCount }
183+ </ span >
184+ ) }
173185 </ button >
174186 ) ;
175187}
@@ -187,10 +199,49 @@ export function CardChain({
187199 onLoadNext,
188200 hasPreviousItems = false ,
189201 hasNextItems = false ,
202+ nextItemCount,
190203 isLoading = false ,
204+ isFetching = false ,
191205 skeletonCount = 6 ,
192206 className,
193207} : CardChainProps ) : JSX . Element {
208+ // --- Slide animation state ---
209+ const prevIdsRef = useRef < ( string | number ) [ ] > ( [ ] ) ;
210+ const [ slideDirection , setSlideDirection ] = useState < 'left' | 'right' | null > ( null ) ;
211+ const [ animationKey , setAnimationKey ] = useState ( 0 ) ;
212+ const fetchDirectionRef = useRef < 'left' | 'right' | null > ( null ) ;
213+
214+ useEffect ( ( ) => {
215+ if ( isLoading || items . length === 0 ) return ;
216+
217+ const currentIds = items . map ( i => i . id ) ;
218+ const prevIds = prevIdsRef . current ;
219+
220+ // Skip initial render or identical item sets
221+ if ( prevIds . length > 0 && JSON . stringify ( currentIds ) !== JSON . stringify ( prevIds ) ) {
222+ const newFirstId = currentIds [ 0 ] ;
223+ const prevFirstId = prevIds [ 0 ] ;
224+
225+ // Compare as numbers if both are numeric, otherwise use string comparison
226+ const newFirst = Number ( newFirstId ) ;
227+ const prevFirst = Number ( prevFirstId ) ;
228+ const isNumeric = ! Number . isNaN ( newFirst ) && ! Number . isNaN ( prevFirst ) ;
229+
230+ if ( isNumeric ? newFirst > prevFirst : newFirstId > prevFirstId ) {
231+ setSlideDirection ( 'left' ) ; // newer blocks → cards enter from right
232+ } else {
233+ setSlideDirection ( 'right' ) ; // older blocks → cards enter from left
234+ }
235+ setAnimationKey ( k => k + 1 ) ;
236+ fetchDirectionRef . current = null ;
237+ }
238+
239+ prevIdsRef . current = currentIds ;
240+ } , [ items , isLoading ] ) ;
241+
242+ const staggerMs = 50 ;
243+ const durationMs = 350 ;
244+
194245 // Default wrapper just renders children
195246 const wrapItem = ( item : CardChainItem , index : number , children : ReactNode ) : ReactNode => {
196247 if ( renderItemWrapper ) {
@@ -231,8 +282,12 @@ export function CardChain({
231282 { onLoadPrevious && (
232283 < NavArrow
233284 direction = "left"
234- onClick = { onLoadPrevious }
285+ onClick = { ( ) => {
286+ fetchDirectionRef . current = 'left' ;
287+ onLoadPrevious ( ) ;
288+ } }
235289 disabled = { ! hasPreviousItems || isLoading }
290+ loading = { isFetching && fetchDirectionRef . current === 'left' }
236291 title = { hasPreviousItems ? 'Load previous items' : 'No previous items available' }
237292 />
238293 ) }
@@ -246,13 +301,51 @@ export function CardChain({
246301 items . map ( ( item , index ) => {
247302 const isLast = index === items . length - 1 ;
248303 const cardElement = < ChainCard item = { item } isLast = { isLast } highlightBadgeText = { highlightBadgeText } /> ;
249- return wrapItem ( item , index , cardElement ) ;
304+
305+ // Apply stagger animation when direction is set
306+ const animStyle =
307+ slideDirection != null
308+ ? {
309+ animation : `chain-slide-${ slideDirection } ${ durationMs } ms ease-out both` ,
310+ animationDelay : `${
311+ slideDirection === 'left'
312+ ? ( items . length - 1 - index ) * staggerMs // rightmost leads
313+ : index * staggerMs // leftmost leads
314+ } ms`,
315+ }
316+ : undefined ;
317+
318+ return (
319+ < div
320+ key = { `${ item . id } -${ animationKey } ` }
321+ className = "flex-1"
322+ style = { animStyle }
323+ onAnimationEnd = {
324+ // Clear direction after the last card finishes
325+ index === ( slideDirection === 'left' ? 0 : items . length - 1 )
326+ ? ( ) => setSlideDirection ( null )
327+ : undefined
328+ }
329+ >
330+ { wrapItem ( item , index , cardElement ) }
331+ </ div >
332+ ) ;
250333 } ) }
251334 </ div >
252335
253336 { /* Right arrow - load next items (only show when there are next items) */ }
254337 { onLoadNext && hasNextItems && (
255- < NavArrow direction = "right" onClick = { onLoadNext } disabled = { isLoading } title = "Load next items" />
338+ < NavArrow
339+ direction = "right"
340+ onClick = { ( ) => {
341+ fetchDirectionRef . current = 'right' ;
342+ onLoadNext ( ) ;
343+ } }
344+ disabled = { isLoading }
345+ loading = { isFetching && fetchDirectionRef . current === 'right' }
346+ badgeCount = { nextItemCount }
347+ title = "Load next items"
348+ />
256349 ) }
257350 </ div >
258351 </ div >
0 commit comments