diff --git a/packages/widget-react/src/components/form/hooks.ts b/packages/widget-react/src/components/form/hooks.ts index 2053f0b..0aa593b 100644 --- a/packages/widget-react/src/components/form/hooks.ts +++ b/packages/widget-react/src/components/form/hooks.ts @@ -7,7 +7,9 @@ export function useAutoFocus() { useEffect(() => { if (isSmall) return - ref.current?.focus() + // wait 250ms to allow transition to complete + const timeout = setTimeout(() => ref.current?.focus(), 250) + return () => clearTimeout(timeout) }, [isSmall]) return ref diff --git a/packages/widget-react/src/lib/router/MemoryRouter.tsx b/packages/widget-react/src/lib/router/MemoryRouter.tsx index 6b3f665..de24520 100644 --- a/packages/widget-react/src/lib/router/MemoryRouter.tsx +++ b/packages/widget-react/src/lib/router/MemoryRouter.tsx @@ -14,10 +14,14 @@ interface MemoryRouterProps { const MemoryRouter = ({ children, initialEntry }: PropsWithChildren) => { const [history, setHistory] = useState([initialEntry ?? { path: "/" }]) + // we cannot use history to track prevLocation since navigate(-1) will remove the current entry + const [previousLocation, setPreviousLocation] = useState(null) const location = history[history.length - 1] const navigate = useCallback((to: string | number, state?: object) => { setHistory((prev) => { + setPreviousLocation(prev[prev.length - 1]) + if (typeof to === "string") { return [...prev, { path: to, state }] } @@ -45,7 +49,7 @@ const MemoryRouter = ({ children, initialEntry }: PropsWithChildren + {children} ) diff --git a/packages/widget-react/src/lib/router/RouterContext.ts b/packages/widget-react/src/lib/router/RouterContext.ts index 54fbd51..72204d4 100644 --- a/packages/widget-react/src/lib/router/RouterContext.ts +++ b/packages/widget-react/src/lib/router/RouterContext.ts @@ -7,6 +7,7 @@ export interface HistoryEntry { interface RouterContextProps { location: HistoryEntry + previousLocation: HistoryEntry | null history: HistoryEntry[] navigate: (to: string | number, state?: object) => void reset: (to: string, state?: object) => void @@ -23,6 +24,11 @@ export function useLocation() { return location } +export function usePreviousPath() { + const { previousLocation } = useRouterContext() + return previousLocation?.path +} + export function usePath() { const { path } = useLocation() return path diff --git a/packages/widget-react/src/pages/wallet/tabs/Home.module.css b/packages/widget-react/src/pages/wallet/tabs/Home.module.css index 972962c..bd2de19 100644 --- a/packages/widget-react/src/pages/wallet/tabs/Home.module.css +++ b/packages/widget-react/src/pages/wallet/tabs/Home.module.css @@ -1,3 +1,7 @@ +.container { + height: 100%; +} + .nav { display: flex; gap: 8px; diff --git a/packages/widget-react/src/pages/wallet/tabs/Home.tsx b/packages/widget-react/src/pages/wallet/tabs/Home.tsx index 27f3c61..13f6d3f 100644 --- a/packages/widget-react/src/pages/wallet/tabs/Home.tsx +++ b/packages/widget-react/src/pages/wallet/tabs/Home.tsx @@ -1,6 +1,8 @@ +import { useMemo } from "react" import { Tabs } from "radix-ui" import { IconArrowRight, IconSwap } from "@initia/icons-react" -import { Link, useNavigate, usePath } from "@/lib/router" +import { animated, useTransition } from "@react-spring/web" +import { Link, useNavigate, usePath, usePreviousPath } from "@/lib/router" import { useClaimableModal } from "@/pages/bridge/op/reminder" import Scrollable from "@/components/Scrollable" import Assets from "./assets/Assets" @@ -8,14 +10,39 @@ import Nfts from "./nft/Nfts" import Activity from "./activity/Activity" import styles from "./Home.module.css" +const tabs = [ + { label: "Assets", value: "/", component: }, + { label: "NFTs", value: "/nfts", component: }, + { label: "History", value: "/activity", component: }, +] + const Home = () => { useClaimableModal() const navigate = useNavigate() const path = usePath() + const prevPath = usePreviousPath() + + const direction = useMemo(() => { + const currentIndex = tabs.findIndex((t) => t.value === path) + const prevIndex = tabs.findIndex((t) => t.value === prevPath) + + return currentIndex > prevIndex ? 1 : -1 + }, [path, prevPath]) + + const skipAnimation = + !tabs.find((t) => t.value === prevPath) || !tabs.find((t) => t.value === path) + + const transitions = useTransition(path, { + from: { opacity: 0, transform: `translateX(${direction * 100}%)` }, + enter: { opacity: 1, transform: "translateX(0%)" }, + leave: { opacity: 0, transform: `translateX(${direction * -100}%)` }, + config: { tension: 250, friction: 30 }, + immediate: skipAnimation, + }) return ( - +
@@ -30,30 +57,25 @@ const Home = () => { - - Assets - - - - NFTs - - - - History - + {tabs.map((tab) => ( + + {tab.label} + + ))} - - - - - - - - - - - +
+ {transitions((style, item) => { + const tab = tabs.find((t) => t.value === item) + return ( + + + {tab?.component} + + + ) + })} +
) diff --git a/packages/widget-react/src/public/app/Routes.tsx b/packages/widget-react/src/public/app/Routes.tsx index 50e2a5b..eb86f22 100644 --- a/packages/widget-react/src/public/app/Routes.tsx +++ b/packages/widget-react/src/public/app/Routes.tsx @@ -1,5 +1,8 @@ import { useEffect } from "react" -import { useNavigate, usePath } from "@/lib/router" +import { useTransition, animated } from "@react-spring/web" +import { useNavigate, usePath, usePreviousPath } from "@/lib/router" +import { useModal } from "./ModalContext" +import { useAddress } from "../data/hooks" import Connect from "@/pages/connect/Connect" import Home from "@/pages/wallet/tabs/Home" import Send from "@/pages/wallet/txs/send/Send" @@ -12,15 +15,32 @@ import Withdrawals from "@/pages/bridge/op/Withdrawals" import BridgePreview from "@/pages/bridge/BridgePreview" import BridgeHistory from "@/pages/bridge/BridgeHistory" import TxRequest from "@/pages/tx/TxRequest" -import { useAddress } from "../data/hooks" -import { useModal } from "./ModalContext" + +const routes = [ + { path: "/connect", Component: Connect }, + { path: "/", Component: Home }, + { path: "/send", Component: Send, rerender: true }, + { path: "/collection", Component: CollectionDetails }, + { path: "/nft", Component: NftDetails }, + { path: "/nft/send", Component: SendNft, rerender: true }, + { path: "/rollups", Component: ManageChains }, + { path: "/bridge", Component: BridgeForm, renderWithoutAddress: true, rerender: true }, + { path: "/bridge/preview", Component: BridgePreview }, + { path: "/bridge/history", Component: BridgeHistory, renderWithoutAddress: true }, + { path: "/op/withdrawals", Component: Withdrawals }, + { path: "/tx", Component: TxRequest }, +] const Routes = () => { + const rawPath = usePath() + const rawPrevPath = usePreviousPath() const navigate = useNavigate() - const path = usePath() const address = useAddress() const { closeModal } = useModal() + const path = ["/nfts", "/activity"].includes(rawPath) ? "/" : rawPath + const prevPath = ["/nfts", "/activity"].includes(rawPrevPath || "") ? "/" : rawPrevPath + // whenever address changes, navigate to the appropriate path useEffect(() => { closeModal() @@ -28,55 +48,62 @@ const Routes = () => { if (path.startsWith("/bridge/")) { navigate("/bridge") } - if (path === "/collection" || path.startsWith("/nft")) { navigate("/nfts") } - // Run only on address changes, preventing navigation from triggering on path updates. // eslint-disable-next-line react-hooks/exhaustive-deps }, [address]) - if (path === "/connect") { - if (address) return null - return - } + // Compute transition direction + const currentIndex = routes.findIndex((r) => r.path === path) + const prevIndex = routes.findIndex((r) => r.path === prevPath) + const direction = currentIndex >= prevIndex ? 1 : -1 + + const transitions = useTransition(path, { + from: + direction > 0 + ? { opacity: 1, transform: `translateX(100%)` } + : { opacity: 0, transform: `translateX(0%)` }, + enter: { opacity: 1, transform: "translateX(0%)" }, + leave: + direction < 0 + ? { opacity: 1, transform: `translateX(100%)` } + : { opacity: 0, transform: `translateX(0%)` }, + immediate: !prevPath, + }) + + if (path === "/connect" && !address) return + + // FIXME: do we need to block all routes when address is not connected? + if (!address) return null - switch (path) { - case "/bridge": - return - case "/bridge/history": - return - } + return ( +
+ {transitions((style, animatedPath) => { + const route = routes.find((r) => r.path === animatedPath) + if (!route) return null + if (!address && !route.renderWithoutAddress) return null - if (!address) { - return null - } + const { Component, rerender } = route - switch (path) { - case "/": - case "/nfts": - case "/activity": - return - case "/send": - return - case "/collection": - return - case "/nft": - return - case "/nft/send": - return - case "/rollups": - return - case "/bridge/preview": - return - case "/op/withdrawals": - return - case "/tx": - return - case "/blank": - return null - } + return ( + + {rerender ? : } + + ) + })} +
+ ) } export default Routes