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
21 changes: 21 additions & 0 deletions components/motion/wallet-card/account-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
import type { WalletAccount } from "./types";
import { diceBearGlassUrl } from "./utils";

export function AccountAvatar({
account,
className,
}: {
account: WalletAccount;
className?: string;
}) {
if (account.avatar) return <>{account.avatar}</>;
return (
// biome-ignore lint/performance/noImgElement: remote DiceBear SVG, no next/image benefit
<img
src={diceBearGlassUrl(account.id || account.address)}
alt={account.name}
className={cn("h-7 w-7 shrink-0 rounded-full bg-muted", className)}
/>
);
}
199 changes: 199 additions & 0 deletions components/motion/wallet-card/account-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"use client";

import { Check, ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { AccountAvatar } from "./account-avatar";
import { HEAD, ITEM, LIST, MORPH } from "./constants";
import { CopyButton } from "./copy-button";
import type { WalletAccount } from "./types";
import { useDismiss } from "./use-dismiss";
import { truncateAddress } from "./utils";

/**
* Account switcher whose trigger morphs into a panel that grows rightward to
* full width (covering the header icons) and downward at the same time, via a
* shared layoutId. Self-contained — not the generic MorphSelect.
*/
export function AccountSwitcher({
accounts,
activeAccount,
onSelect,
}: {
accounts: WalletAccount[];
activeAccount: WalletAccount | undefined;
onSelect: (id: string) => void;
}) {
const reduce = useReducedMotion() ?? false;
const layoutId = `${useId()}-account`;
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
// Hover only arms after the morph settles — otherwise the panel expands under
// the cursor and flashes a phantom hover bg on whatever item it lands on.
const [armed, setArmed] = useState(false);
const close = useCallback(() => setOpen(false), []);

useDismiss(open, close, rootRef);

useEffect(() => {
if (!open) {
setArmed(false);
return;
}
const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 280);
return () => window.clearTimeout(t);
}, [open, reduce]);

const morph = reduce ? { duration: 0 } : MORPH;

return (
// static (not relative) so the absolute trigger + panel anchor to the
// header row and can span its full width, covering the icons.
<div ref={rootRef} className="min-w-0">
{/* in-flow sizer: reserves the widest possible trigger footprint (every
account name stacked in one grid cell) so the header row width never
changes — neither when the trigger leaves the flow nor when a shorter
account name is selected. */}
<div aria-hidden className={cn(HEAD, "pointer-events-none opacity-0")}>
<AccountAvatar account={activeAccount ?? accounts[0]} />
<span className="grid">
{accounts.map((account) => (
<span
key={account.id}
className="col-start-1 row-start-1 whitespace-nowrap text-sm font-medium"
>
{account.name}
</span>
))}
</span>
<ChevronDown className="h-4 w-4" />
</div>

<AnimatePresence initial={false} mode="popLayout">
{open ? null : (
<motion.button
key="trigger"
layoutId={layoutId}
type="button"
aria-haspopup="listbox"
aria-expanded={false}
onClick={() => setOpen(true)}
transition={morph}
style={{ borderRadius: 16 }}
className={cn(
HEAD,
// shifted left by the padding so the avatar sits flush with the
// card content edge (aligned with the balance + buttons below).
"absolute top-0 -left-2 z-20 max-w-full outline-none transition-colors hover:bg-muted/60",
)}
>
{activeAccount ? (
<>
<AccountAvatar account={activeAccount} />
<motion.span
layout="position"
className="truncate text-sm font-medium text-foreground"
>
{activeAccount.name}
</motion.span>
<motion.span layout="position" className="text-muted-foreground">
<ChevronDown className="h-4 w-4" />
</motion.span>
</>
) : null}
</motion.button>
)}
</AnimatePresence>

<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
role="listbox"
transition={morph}
style={{ borderRadius: 16 }}
className="absolute top-0 -right-2 -left-2 z-30 overflow-hidden border border-border/30 bg-background backdrop-blur-md"
>
<button
type="button"
onClick={() => setOpen(false)}
className={cn(HEAD, "w-full outline-none")}
>
{activeAccount ? <AccountAvatar account={activeAccount} /> : null}
<motion.span
layout="position"
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{activeAccount?.name ?? "Select account"}
</motion.span>
<motion.span
layout="position"
animate={{ rotate: 180 }}
transition={morph}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>

<motion.ul
initial="hidden"
animate="show"
variants={reduce ? undefined : LIST}
className={cn(
"max-h-64 overflow-y-auto p-1.5",
armed ? "" : "pointer-events-none",
)}
>
{accounts.map((account) => {
const selected = account.id === activeAccount?.id;
return (
<motion.li
key={account.id}
variants={reduce ? undefined : ITEM}
className={cn(
"flex items-center rounded-xl pr-1 text-sm transition-colors",
selected
? "bg-muted text-foreground"
: cn(
"text-muted-foreground",
armed && "hover:bg-muted hover:text-foreground",
),
)}
>
<button
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(account.id);
setOpen(false);
}}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-2 text-left outline-none"
>
<AccountAvatar account={account} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">
{account.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{truncateAddress(account.address)}
</span>
</span>
{selected ? (
<Check className="h-4 w-4 shrink-0 text-foreground" />
) : null}
</button>
<CopyButton value={account.address} />
</motion.li>
);
})}
</motion.ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
59 changes: 59 additions & 0 deletions components/motion/wallet-card/actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { ArrowDownToLine, ArrowUp, CreditCard, Repeat } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import type { ComponentType } from "react";
import { SPRING_PRESS } from "@/lib/ease";

type WalletAction = {
key: string;
label: string;
icon: ComponentType<{ className?: string }>;
onClick?: () => void;
};

/**
* Row of primary wallet actions rendered icon-over-label, with a spring press.
*/
export function WalletActions({
onSend,
onDeposit,
onSwap,
onBuy,
}: {
onSend?: () => void;
onDeposit?: () => void;
onSwap?: () => void;
onBuy?: () => void;
}) {
const reduce = useReducedMotion();

const actions: WalletAction[] = [
{ key: "send", label: "Send", icon: ArrowUp, onClick: onSend },
{ key: "deposit", label: "Deposit", icon: ArrowDownToLine, onClick: onDeposit },
{ key: "swap", label: "Swap", icon: Repeat, onClick: onSwap },
{ key: "buy", label: "Buy", icon: CreditCard, onClick: onBuy },
];

return (
<div className="flex items-start justify-between gap-2">
{actions.map(({ key, label, icon: Icon, onClick }) => (
<motion.button
key={key}
type="button"
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className="flex flex-1 flex-col items-center gap-2 outline-none"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-foreground">
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-muted-foreground">
{label}
</span>
</motion.button>
))}
</div>
);
}
69 changes: 69 additions & 0 deletions components/motion/wallet-card/balance-delta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { TrendingDown, TrendingUp } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";

/**
* A transient change indicator for the balance: a tinted pill with a trend
* arrow that pops in whenever the balance moves and persists until it moves
* again.
*/
export function BalanceDelta({
balance,
initialChange,
}: {
balance: number;
initialChange?: number;
}) {
const reduce = useReducedMotion();
const prevRef = useRef(balance);
const [delta, setDelta] = useState<{ id: number; amount: number } | null>(
initialChange ? { id: 0, amount: initialChange } : null,
);

// Persist the last change until the balance moves again — don't auto-hide.
useEffect(() => {
const diff = balance - prevRef.current;
prevRef.current = balance;
if (diff === 0) return;
setDelta({ id: Date.now(), amount: diff });
}, [balance]);

const up = (delta?.amount ?? 0) > 0;

return (
<div className="mt-2 flex h-7 items-center justify-center">
<AnimatePresence mode="wait">
{delta ? (
<motion.span
key={delta.id}
initial={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.9 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums",
up
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400",
)}
>
{up ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
{up ? "+" : "-"}$
{Math.abs(delta.amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</motion.span>
) : null}
</AnimatePresence>
</div>
);
}
18 changes: 18 additions & 0 deletions components/motion/wallet-card/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Transition, Variants } from "motion/react";

// Trigger box grows into the full-width panel and back — one shared surface.
export const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.22 };

export const LIST: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.12 } },
};

export const ITEM: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};

// Shared padding for the account trigger + panel header so the avatar/name stay
// put as the box morphs — the panel reads as the trigger itself growing open.
export const HEAD = "flex items-center gap-2 px-2 py-1.5 text-left";
Loading
Loading