Skip to content

Commit 77f14f0

Browse files
tonykhaovRobinVncnt
authored andcommitted
refactor: use declarative animation API
1 parent 8b52f5a commit 77f14f0

File tree

7 files changed

+229
-181
lines changed

7 files changed

+229
-181
lines changed
Lines changed: 71 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
import React, { ReactNode } from "react";
1+
import React, { ReactNode, useRef, useState, useEffect } from "react";
2+
import { isValidReactElement } from "@ledgerhq/react-ui";
23
import { cn } from "LLD/utils/cn";
34
import { SlidesContext } from "./context";
45
import { Content } from "./components/Content";
56
import { StaticSection } from "./components/StaticSection";
67
import { useSlidesViewModel } from "./useSlidesViewModel";
7-
import { isValidReactElement } from "@ledgerhq/react-ui";
8+
9+
const SLIDE_DURATION_MS = 300;
10+
11+
const SLIDE_CLASSES = {
12+
forward: {
13+
enter: "animate-slide-in-from-right",
14+
exit: "animate-slide-out-to-left",
15+
},
16+
backward: {
17+
enter: "animate-slide-in-from-left",
18+
exit: "animate-slide-out-to-right",
19+
},
20+
} as const;
21+
22+
type ContentElement = React.ReactElement<{
23+
children: ReactNode;
24+
className?: string;
25+
style?: React.CSSProperties;
26+
}>;
27+
const isContentElement = (child: React.ReactNode): child is ContentElement =>
28+
isValidReactElement(child) && child.type === Content;
829

930
export type SlidesProps = {
1031
children: ReactNode;
@@ -19,61 +40,64 @@ export function Slides({
1940
initialSlideIndex = 0,
2041
className,
2142
}: Readonly<SlidesProps>) {
22-
const { contextValue } = useSlidesViewModel({
43+
const contextValue = useSlidesViewModel({
2344
children,
2445
onSlideChange,
2546
initialSlideIndex,
2647
});
2748

28-
type ContentElement = React.ReactElement<{
29-
children: ReactNode;
30-
style?: React.CSSProperties;
31-
className?: string;
32-
}>;
33-
const isContentElement = (child: React.ReactNode): child is ContentElement =>
34-
isValidReactElement(child) && child.type === Content;
49+
const [displayedIndex, setDisplayedIndex] = useState(contextValue.initialIndex);
50+
const isExiting = displayedIndex !== contextValue.currentIndex;
51+
const transitionDirectionRef = useRef<1 | -1>(1);
3552

36-
const renderChildren = () =>
37-
React.Children.map(children, child => {
38-
if (isContentElement(child)) {
39-
const slideItems = React.Children.toArray(child.props.children);
53+
if (isExiting) {
54+
transitionDirectionRef.current = contextValue.currentIndex > displayedIndex ? 1 : -1;
55+
}
56+
const direction = transitionDirectionRef.current;
57+
const slideClasses = direction > 0 ? SLIDE_CLASSES.forward : SLIDE_CLASSES.backward;
4058

41-
return (
42-
<div
43-
style={child.props.style}
44-
className={cn("grid min-h-0 flex-1 overflow-hidden", child.props.className)}
45-
>
46-
{slideItems.map((slideItem, index) => {
47-
const isActive = index === contextValue.currentIndex;
48-
const isOutgoing = index === contextValue.previousIndex;
49-
const zIndex = isOutgoing ? 2 : isActive ? 1 : 0;
50-
return (
51-
<div
52-
key={index}
53-
role="group"
54-
aria-hidden={!isActive}
55-
className={cn(
56-
"col-start-1 row-start-1 min-h-0 flex-1 overflow-hidden",
57-
!isActive && "pointer-events-none",
58-
)}
59-
style={{ zIndex }}
60-
>
61-
{slideItem}
62-
</div>
63-
);
64-
})}
65-
</div>
66-
);
67-
}
68-
if (isValidReactElement(child) && child.type === StaticSection) {
69-
return child;
70-
}
71-
return null;
72-
});
59+
useEffect(() => {
60+
if (displayedIndex !== contextValue.currentIndex) {
61+
const t = setTimeout(() => setDisplayedIndex(contextValue.currentIndex), SLIDE_DURATION_MS);
62+
return () => clearTimeout(t);
63+
}
64+
}, [contextValue.currentIndex, displayedIndex]);
7365

7466
return (
7567
<SlidesContext.Provider value={contextValue}>
76-
<div className={cn("flex flex-1 flex-col", className)}>{renderChildren()}</div>
68+
<div className={cn("flex flex-1 flex-col", className)}>
69+
{React.Children.map(children, child => {
70+
if (isContentElement(child)) {
71+
const slideItems = React.Children.toArray(child.props.children);
72+
const activeSlide = slideItems[displayedIndex];
73+
const contentClassName = "className" in child.props ? child.props.className : undefined;
74+
const contentStyle = "style" in child.props ? child.props.style : undefined;
75+
76+
return (
77+
<div
78+
className={cn("grid min-h-0 flex-1 overflow-hidden", contentClassName)}
79+
style={contentStyle}
80+
>
81+
{activeSlide != null && (
82+
<div
83+
key={displayedIndex}
84+
className={cn(
85+
isExiting ? slideClasses.exit : slideClasses.enter,
86+
"col-start-1 row-start-1 min-h-0 flex-1 overflow-hidden",
87+
)}
88+
>
89+
{activeSlide}
90+
</div>
91+
)}
92+
</div>
93+
);
94+
}
95+
if (isValidReactElement(child) && child.type === StaticSection) {
96+
return child;
97+
}
98+
return null;
99+
})}
100+
</div>
77101
</SlidesContext.Provider>
78102
);
79103
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from "react";
2+
import { render, screen, waitFor } from "tests/testSetup";
3+
import userEvent from "@testing-library/user-event";
4+
import { Slides, useSlidesContext } from "../index";
5+
6+
function TestFooter() {
7+
const { goToNext } = useSlidesContext();
8+
return (
9+
<div>
10+
<button type="button" onClick={goToNext}>
11+
Continue
12+
</button>
13+
</div>
14+
);
15+
}
16+
17+
function TestProgressIndicator() {
18+
const { currentIndex, totalSlides } = useSlidesContext();
19+
return (
20+
<div data-testid="progress">
21+
{currentIndex + 1} / {totalSlides}
22+
</div>
23+
);
24+
}
25+
26+
describe("Slides", () => {
27+
it("renders first slide and exposes navigation via context", () => {
28+
render(
29+
<Slides initialSlideIndex={0}>
30+
<Slides.Content>
31+
<Slides.Content.Item>
32+
<div data-testid="slide-0">Slide 0</div>
33+
</Slides.Content.Item>
34+
<Slides.Content.Item>
35+
<div data-testid="slide-1">Slide 1</div>
36+
</Slides.Content.Item>
37+
</Slides.Content>
38+
<Slides.Footer>
39+
<TestFooter />
40+
</Slides.Footer>
41+
<Slides.ProgressIndicator>
42+
<TestProgressIndicator />
43+
</Slides.ProgressIndicator>
44+
</Slides>,
45+
);
46+
47+
expect(screen.getByTestId("slide-0")).toBeInTheDocument();
48+
expect(screen.queryByTestId("slide-1")).not.toBeInTheDocument();
49+
expect(screen.getByTestId("progress")).toHaveTextContent("1 / 2");
50+
});
51+
52+
it("advances to next slide when Continue is clicked", async () => {
53+
const user = userEvent.setup();
54+
render(
55+
<Slides initialSlideIndex={0}>
56+
<Slides.Content>
57+
<Slides.Content.Item>
58+
<div data-testid="slide-0">Slide 0</div>
59+
</Slides.Content.Item>
60+
<Slides.Content.Item>
61+
<div data-testid="slide-1">Slide 1</div>
62+
</Slides.Content.Item>
63+
</Slides.Content>
64+
<Slides.Footer>
65+
<TestFooter />
66+
</Slides.Footer>
67+
<Slides.ProgressIndicator>
68+
<TestProgressIndicator />
69+
</Slides.ProgressIndicator>
70+
</Slides>,
71+
);
72+
73+
await user.click(screen.getByRole("button", { name: "Continue" }));
74+
75+
await waitFor(() => {
76+
expect(screen.queryByTestId("slide-0")).not.toBeInTheDocument();
77+
expect(screen.getByTestId("slide-1")).toBeInTheDocument();
78+
});
79+
expect(screen.getByTestId("progress")).toHaveTextContent("2 / 2");
80+
});
81+
82+
it("calls onSlideChange when index changes", async () => {
83+
const user = userEvent.setup();
84+
const onSlideChange = jest.fn();
85+
86+
render(
87+
<Slides initialSlideIndex={0} onSlideChange={onSlideChange}>
88+
<Slides.Content>
89+
<Slides.Content.Item>
90+
<div data-testid="slide-0">Slide 0</div>
91+
</Slides.Content.Item>
92+
<Slides.Content.Item>
93+
<div data-testid="slide-1">Slide 1</div>
94+
</Slides.Content.Item>
95+
</Slides.Content>
96+
<Slides.Footer>
97+
<TestFooter />
98+
</Slides.Footer>
99+
</Slides>,
100+
);
101+
102+
expect(onSlideChange).not.toHaveBeenCalled();
103+
104+
await user.click(screen.getByRole("button", { name: "Continue" }));
105+
106+
expect(onSlideChange).toHaveBeenCalledTimes(1);
107+
expect(onSlideChange).toHaveBeenCalledWith(1);
108+
});
109+
});

apps/ledger-live-desktop/src/mvvm/components/Slides/components/Content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { cn } from "LLD/utils/cn";
33

44
function ContentItem({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
55
return (
6-
<div {...props} className={cn("w-full shrink-0 snap-start", className)}>
6+
<div {...props} className={cn("size-full min-h-0", className)}>
77
{children}
88
</div>
99
);

apps/ledger-live-desktop/src/mvvm/components/Slides/context.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { createContext, useContext } from "react";
2-
import type { MotionValue } from "framer-motion";
32

43
export interface SlidesContextValue {
54
currentIndex: number;
6-
previousIndex: number;
7-
progress: MotionValue<number>;
85
totalSlides: number;
6+
initialIndex: number;
97
goToNext: () => void;
108
goToPrevious: () => void;
119
goToSlide: (index: number) => void;
Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,31 @@
1-
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2-
import { animate, useMotionValue } from "framer-motion";
1+
import React, { ReactNode, useCallback, useMemo, useState } from "react";
2+
import { isValidReactElement } from "@ledgerhq/react-ui";
33
import { Content } from "./components/Content";
44
import { SlidesContextValue } from "./context";
5-
import { isValidReactElement } from "@ledgerhq/react-ui";
6-
7-
const PROGRESS_ANIMATION_DURATION_S = 0.5;
85

96
type UseSlidesViewModelParams = {
107
children: ReactNode;
118
onSlideChange?: (index: number) => void;
129
initialSlideIndex?: number;
1310
};
1411

15-
type UseSlidesViewModelReturn = {
16-
contextValue: SlidesContextValue;
17-
};
12+
type UseSlidesViewModelReturn = SlidesContextValue;
1813

1914
export function useSlidesViewModel({
2015
children,
2116
onSlideChange,
2217
initialSlideIndex = 0,
2318
}: UseSlidesViewModelParams): UseSlidesViewModelReturn {
24-
const [currentIndex, setCurrentIndex] = useState(initialSlideIndex);
25-
const [previousIndex, setPreviousIndex] = useState(initialSlideIndex);
26-
const currentIndexRef = useRef(initialSlideIndex);
27-
const progress = useMotionValue(initialSlideIndex);
28-
29-
useEffect(() => {
30-
setPreviousIndex(currentIndexRef.current);
31-
currentIndexRef.current = currentIndex;
32-
}, [currentIndex]);
33-
34-
useEffect(() => {
35-
if (currentIndex === previousIndex) {
36-
progress.set(currentIndex);
37-
return;
38-
}
39-
const controls = animate(progress, currentIndex, {
40-
duration: PROGRESS_ANIMATION_DURATION_S,
41-
ease: "easeOut",
42-
});
43-
return () => controls.stop();
44-
}, [currentIndex, previousIndex, progress]);
45-
4619
const contentChild = React.Children.toArray(children).find(
4720
(child): child is React.ReactElement<{ children: ReactNode }> =>
4821
isValidReactElement(child) && child.type === Content,
4922
);
5023

5124
const totalSlides = contentChild ? React.Children.toArray(contentChild.props.children).length : 0;
25+
const clampedInitialIndex =
26+
totalSlides > 0 ? Math.min(Math.max(0, initialSlideIndex), totalSlides - 1) : 0;
27+
28+
const [currentIndex, setCurrentIndex] = useState(clampedInitialIndex);
5229

5330
const goToSlide = useCallback(
5431
(index: number) => {
@@ -76,15 +53,14 @@ export function useSlidesViewModel({
7653
const contextValue = useMemo(
7754
() => ({
7855
currentIndex,
79-
previousIndex,
80-
progress,
8156
totalSlides,
57+
initialIndex: clampedInitialIndex,
8258
goToNext,
8359
goToPrevious,
8460
goToSlide,
8561
}),
86-
[currentIndex, previousIndex, totalSlides, goToNext, goToPrevious, goToSlide, progress],
62+
[currentIndex, totalSlides, clampedInitialIndex, goToNext, goToPrevious, goToSlide],
8763
);
8864

89-
return { contextValue };
65+
return contextValue;
9066
}

apps/ledger-live-desktop/src/mvvm/features/WalletV4Tour/Drawer/WalletV4TourDialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ export const WalletV4TourDialog = ({ isOpen, onClose }: WalletV4TourDialogProps)
4242
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
4343
<DialogContent className="flex h-screen min-h-0 flex-col">
4444
<DialogHeader appearance="compact" onClose={onClose} />
45-
<Slides>
45+
<Slides initialSlideIndex={0}>
4646
<Slides.Content>
47-
{slides.map((slide, index) => (
47+
{slides.map(slide => (
4848
<Slides.Content.Item key={slide.title}>
49-
<SlideItem index={index} title={slide.title} description={slide.description} />
49+
<SlideItem title={slide.title} description={slide.description} />
5050
</Slides.Content.Item>
5151
))}
5252
</Slides.Content>

0 commit comments

Comments
 (0)