Skip to content

Commit 6ec5703

Browse files
authored
Merge pull request #423 from ethpandaops/chore/gas-cleanup
feat(gas): cleanup
2 parents 00e34b1 + 77e7de5 commit 6ec5703

File tree

5 files changed

+269
-93
lines changed

5 files changed

+269
-93
lines changed

src/components/DataDisplay/CardChain/CardChain.tsx

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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';
33
import clsx from 'clsx';
44
import 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>

src/components/DataDisplay/CardChain/CardChain.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ export interface CardChainProps {
3636
hasPreviousItems?: boolean;
3737
/** Whether there are next items to load */
3838
hasNextItems?: boolean;
39+
/** Badge count shown on the "next" arrow (e.g., number of new items available) */
40+
nextItemCount?: number;
3941
/** Loading state - shows skeleton items */
4042
isLoading?: boolean;
43+
/** Background fetching state - shows spinner on nav arrows */
44+
isFetching?: boolean;
4145
/** Number of skeleton items to show when loading (default: 6) */
4246
skeletonCount?: number;
4347
/** Additional CSS class for the container */

src/index.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
opacity: 1;
270270
}
271271
}
272+
272273
}
273274

274275
/* Semantic token mappings
@@ -398,3 +399,28 @@
398399
opacity: 1;
399400
}
400401
}
402+
403+
/* CardChain slide animations for conveyor belt effect.
404+
* Placed outside @theme inline so they're always emitted
405+
* (keyframes inside @theme are only output when referenced by a token). */
406+
@keyframes chain-slide-left {
407+
from {
408+
opacity: 0;
409+
transform: translateX(60px);
410+
}
411+
to {
412+
opacity: 1;
413+
transform: translateX(0);
414+
}
415+
}
416+
417+
@keyframes chain-slide-right {
418+
from {
419+
opacity: 0;
420+
transform: translateX(-60px);
421+
}
422+
to {
423+
opacity: 1;
424+
transform: translateX(0);
425+
}
426+
}

0 commit comments

Comments
 (0)