Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 104 additions & 11 deletions src/components/DataDisplay/CardChain/CardChain.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JSX, ReactNode } from 'react';
import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react';
import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { CardChainItem, CardChainProps } from './CardChain.types';

Expand Down Expand Up @@ -148,28 +148,40 @@ function NavArrow({
direction,
onClick,
disabled,
loading,
badgeCount,
title,
}: {
direction: 'left' | 'right';
onClick?: () => void;
disabled: boolean;
loading?: boolean;
badgeCount?: number;
title: string;
}): JSX.Element {
const Icon = direction === 'left' ? ChevronLeftIcon : ChevronRightIcon;
const showBadge = badgeCount !== undefined && badgeCount > 0 && !loading;

return (
<button
onClick={onClick}
disabled={disabled}
disabled={disabled || loading}
className={clsx(
'z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 transition-all',
!disabled
? 'border-border bg-surface text-muted hover:border-primary hover:bg-primary/10 hover:text-primary'
: 'cursor-not-allowed border-border/50 bg-surface/50 text-muted/30'
'relative z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 transition-all',
loading
? 'border-primary/50 bg-primary/5 text-primary'
: !disabled
? 'border-border bg-surface text-muted hover:border-primary hover:bg-primary/10 hover:text-primary'
: 'cursor-not-allowed border-border/50 bg-surface/50 text-muted/30'
)}
title={title}
>
<Icon className="size-5" />
{loading ? <ArrowPathIcon className="size-5 animate-spin" /> : <Icon className="size-5" />}
{showBadge && (
<span className="absolute -top-2 -right-2 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white">
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</button>
);
}
Expand All @@ -187,10 +199,49 @@ export function CardChain({
onLoadNext,
hasPreviousItems = false,
hasNextItems = false,
nextItemCount,
isLoading = false,
isFetching = false,
skeletonCount = 6,
className,
}: CardChainProps): JSX.Element {
// --- Slide animation state ---
const prevIdsRef = useRef<(string | number)[]>([]);
const [slideDirection, setSlideDirection] = useState<'left' | 'right' | null>(null);
const [animationKey, setAnimationKey] = useState(0);
const fetchDirectionRef = useRef<'left' | 'right' | null>(null);

useEffect(() => {
if (isLoading || items.length === 0) return;

const currentIds = items.map(i => i.id);
const prevIds = prevIdsRef.current;

// Skip initial render or identical item sets
if (prevIds.length > 0 && JSON.stringify(currentIds) !== JSON.stringify(prevIds)) {
const newFirstId = currentIds[0];
const prevFirstId = prevIds[0];

// Compare as numbers if both are numeric, otherwise use string comparison
const newFirst = Number(newFirstId);
const prevFirst = Number(prevFirstId);
const isNumeric = !Number.isNaN(newFirst) && !Number.isNaN(prevFirst);

if (isNumeric ? newFirst > prevFirst : newFirstId > prevFirstId) {
setSlideDirection('left'); // newer blocks → cards enter from right
} else {
setSlideDirection('right'); // older blocks → cards enter from left
}
setAnimationKey(k => k + 1);
fetchDirectionRef.current = null;
}

prevIdsRef.current = currentIds;
}, [items, isLoading]);

const staggerMs = 50;
const durationMs = 350;

// Default wrapper just renders children
const wrapItem = (item: CardChainItem, index: number, children: ReactNode): ReactNode => {
if (renderItemWrapper) {
Expand Down Expand Up @@ -231,8 +282,12 @@ export function CardChain({
{onLoadPrevious && (
<NavArrow
direction="left"
onClick={onLoadPrevious}
onClick={() => {
fetchDirectionRef.current = 'left';
onLoadPrevious();
}}
disabled={!hasPreviousItems || isLoading}
loading={isFetching && fetchDirectionRef.current === 'left'}
title={hasPreviousItems ? 'Load previous items' : 'No previous items available'}
/>
)}
Expand All @@ -246,13 +301,51 @@ export function CardChain({
items.map((item, index) => {
const isLast = index === items.length - 1;
const cardElement = <ChainCard item={item} isLast={isLast} highlightBadgeText={highlightBadgeText} />;
return wrapItem(item, index, cardElement);

// Apply stagger animation when direction is set
const animStyle =
slideDirection != null
? {
animation: `chain-slide-${slideDirection} ${durationMs}ms ease-out both`,
animationDelay: `${
slideDirection === 'left'
? (items.length - 1 - index) * staggerMs // rightmost leads
: index * staggerMs // leftmost leads
}ms`,
}
: undefined;

return (
<div
key={`${item.id}-${animationKey}`}
className="flex-1"
style={animStyle}
onAnimationEnd={
// Clear direction after the last card finishes
index === (slideDirection === 'left' ? 0 : items.length - 1)
? () => setSlideDirection(null)
: undefined
}
>
{wrapItem(item, index, cardElement)}
</div>
);
})}
</div>

{/* Right arrow - load next items (only show when there are next items) */}
{onLoadNext && hasNextItems && (
<NavArrow direction="right" onClick={onLoadNext} disabled={isLoading} title="Load next items" />
<NavArrow
direction="right"
onClick={() => {
fetchDirectionRef.current = 'right';
onLoadNext();
}}
disabled={isLoading}
loading={isFetching && fetchDirectionRef.current === 'right'}
badgeCount={nextItemCount}
title="Load next items"
/>
)}
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/components/DataDisplay/CardChain/CardChain.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ export interface CardChainProps {
hasPreviousItems?: boolean;
/** Whether there are next items to load */
hasNextItems?: boolean;
/** Badge count shown on the "next" arrow (e.g., number of new items available) */
nextItemCount?: number;
/** Loading state - shows skeleton items */
isLoading?: boolean;
/** Background fetching state - shows spinner on nav arrows */
isFetching?: boolean;
/** Number of skeleton items to show when loading (default: 6) */
skeletonCount?: number;
/** Additional CSS class for the container */
Expand Down
26 changes: 26 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
opacity: 1;
}
}

}

/* Semantic token mappings
Expand Down Expand Up @@ -398,3 +399,28 @@
opacity: 1;
}
}

/* CardChain slide animations for conveyor belt effect.
* Placed outside @theme inline so they're always emitted
* (keyframes inside @theme are only output when referenced by a token). */
@keyframes chain-slide-left {
from {
opacity: 0;
transform: translateX(60px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes chain-slide-right {
from {
opacity: 0;
transform: translateX(-60px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
Loading