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
174 changes: 174 additions & 0 deletions src/ui/components/TransactionSigner/Toaster/ToasterActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import React, { useCallback, useMemo } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import CopyIcon from 'jsx:src/ui/assets/copy.svg';
import CheckIcon from 'jsx:src/ui/assets/check.svg';
import LinkIcon from 'jsx:src/ui/assets/new-window.svg';
import { useCopyToClipboard } from 'src/ui/shared/useCopyToClipboard';
import { prepareForHref } from 'src/ui/shared/prepareForHref';
import * as s from './styles.module.css';
import type { ToasterTarget } from './useToasterTarget';

const ICON_SIZE = 20;

// Fade in from a blur while sliding in from the left, so the buttons feel like
// they emerge from under the text as the pill widens to make room for them.
function enterAnimation(reducedMotion: boolean, index: number) {
if (reducedMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.1 },
};
}
return {
initial: { opacity: 0, x: -10, filter: 'blur(4px)' },
animate: { opacity: 1, x: 0, filter: 'blur(0px)' },
exit: {
opacity: 0,
x: -10,
filter: 'blur(4px)',
// Leave immediately — the entrance delay below must not apply on the
// way out.
transition: { duration: 0.18, delay: 0 },
},
transition: {
duration: 0.3,
ease: [0.32, 0.72, 0, 1] as const,
// The pill widens first; let it lead, and stagger the second button
// behind the first.
delay: 0.12 + index * 0.06,
},
};
}

/**
* One circular action in the pill. Renders an anchor when it navigates
* somewhere, a button otherwise. Both stop propagation: the pill itself is a
* click target (History) and a drag handle.
*/
function ToasterAction({
index,
reducedMotion,
label,
href,
onClick,
children,
}: {
index: number;
reducedMotion: boolean;
label: string;
href?: string;
onClick?: () => void;
children: React.ReactNode;
}) {
const handleClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
onClick?.();
},
[onClick]
);
const commonProps = {
className: s.actionButton,
'aria-label': label,
title: label,
onClick: handleClick,
// Pointer-down would otherwise start a drag of the pill underneath.
onPointerDown: (event: React.PointerEvent) => event.stopPropagation(),
whileHover: reducedMotion ? undefined : { scale: 1.08 },
whileTap: reducedMotion ? undefined : { scale: 0.94 },
...enterAnimation(reducedMotion, index),
};
return href ? (
<motion.a
{...commonProps}
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</motion.a>
) : (
<motion.button {...commonProps} type="button">
{children}
</motion.button>
);
}

/**
* Copy-hash and open-in-explorer actions shown on the right of the pill in the
* success state. Each renders only when its target exists.
*/
export function ToasterActions({
target,
reducedMotion,
}: {
target: ToasterTarget;
reducedMotion: boolean;
}) {
const { hash, explorerUrl } = target;
const { handleCopy, isSuccess } = useCopyToClipboard({ text: hash ?? '' });

const href = useMemo(
() => (explorerUrl ? prepareForHref(explorerUrl)?.toString() : undefined),
[explorerUrl]
);

if (!hash && !href) {
return null;
}

return (
<motion.div className={s.actions}>
{hash ? (
<ToasterAction
index={0}
reducedMotion={reducedMotion}
label="Copy transaction hash"
onClick={handleCopy}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={isSuccess ? 'copied' : 'copy'}
className={s.actionIcon}
style={isSuccess ? { color: 'var(--positive-500)' } : undefined}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.6, filter: 'blur(4px)' }
}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.6, filter: 'blur(4px)' }
}
transition={
reducedMotion ? { duration: 0.1 } : { duration: 0.18 }
}
>
{isSuccess ? (
<CheckIcon style={{ width: ICON_SIZE, height: ICON_SIZE }} />
) : (
<CopyIcon style={{ width: ICON_SIZE, height: ICON_SIZE }} />
)}
</motion.span>
</AnimatePresence>
</ToasterAction>
) : null}
{href ? (
<ToasterAction
index={hash ? 1 : 0}
reducedMotion={reducedMotion}
label="View in explorer"
href={href}
>
<span className={s.actionIcon}>
<LinkIcon style={{ width: ICON_SIZE, height: ICON_SIZE }} />
</span>
</ToasterAction>
) : null}
</motion.div>
);
}
41 changes: 34 additions & 7 deletions src/ui/components/TransactionSigner/Toaster/TransactionToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ToasterView } from '../types';
import { ToasterArrow } from './ToasterArrow';
import { ToasterIcons } from './ToasterIcons';
import { ToasterStatusBadge } from './ToasterStatusBadge';
import { ToasterActions } from './ToasterActions';
import { useToasterTarget } from './useToasterTarget';
import { useReducedMotion } from './useReducedMotion';
import {
useToasterSession,
Expand Down Expand Up @@ -269,6 +271,7 @@ function ToasterContent({
const view = step?.toaster;
const title = getTitle(view, terminal);
const subtitle = getSubtitle(view, terminal);
const target = useToasterTarget(step);

return (
<motion.div
Expand Down Expand Up @@ -355,17 +358,28 @@ function ToasterContent({
pendingQueueCount={pendingQueueCount}
/>
</motion.div>
{/* Copy / explorer actions. Mounting them widens .measureWrap, which the
pill follows with the same spring that drives the text reveal. */}
<AnimatePresence initial={false}>
{terminal === 'success' && textRevealed ? (
<ToasterActions
key="actions"
target={target}
reducedMotion={reducedMotion}
/>
) : null}
</AnimatePresence>
</motion.div>
);
}

export function TransactionToaster() {
const session = useToasterSession();
const { setHovered, ...session } = useToasterSession();
const reducedMotion = useReducedMotion();
const navigate = useNavigate();

const [measureRef, { width: contentWidth }] = useMeasure<HTMLDivElement>();
const pillRef = useRef<HTMLButtonElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const { computeNextPosition } = useSnapPosition({
pillRef,
});
Expand Down Expand Up @@ -400,12 +414,15 @@ export function TransactionToaster() {
);

// The parent component stays mounted across toaster sessions; only the
// inner motion.button mounts/unmounts via AnimatePresence. Reset pill-local
// inner motion.div pill mounts/unmounts via AnimatePresence. Reset pill-local
// state AFTER the exit animation completes (via AnimatePresence's
// onExitComplete) so the next entrance starts from a clean slate — no
// leftover drag offset, entrance gate re-armed, default exit direction.
// Resetting earlier would interrupt the exit animation in flight.
const handleExitComplete = useCallback(() => {
// The pointer can't leave a pill that's gone — release the hover hold
// explicitly, or the next session would never auto-dismiss.
setHovered(false);
if (expandTimerRef.current != null) {
clearTimeout(expandTimerRef.current);
expandTimerRef.current = null;
Expand All @@ -415,7 +432,7 @@ export function TransactionToaster() {
setHasEntered(false);
setEntrancePhase('compact');
setExitQuadrant('top-center');
}, [dragX, dragY]);
}, [dragX, dragY, setHovered]);

const handleClick = useCallback(() => {
if (justDraggedRef.current) {
Expand All @@ -426,6 +443,9 @@ export function TransactionToaster() {
}, [navigate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Enter/Space on a nested action (copy / explorer) activates that
// action; it must not also navigate.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate(HISTORY_PATH);
Expand Down Expand Up @@ -469,10 +489,11 @@ export function TransactionToaster() {
<div ref={dragAreaRef} className={s.dragArea} />
<AnimatePresence onExitComplete={handleExitComplete}>
{session.visible ? (
<motion.button
<motion.div
ref={pillRef}
key="pill"
type="button"
role="button"
tabIndex={0}
className={s.pill}
style={{
...getAnchorStyle(position),
Expand Down Expand Up @@ -549,6 +570,12 @@ export function TransactionToaster() {
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
// Hovering (or focusing anything inside) holds the pill open so
// the success actions stay reachable.
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
>
<div ref={measureRef} className={s.measureWrap}>
<ToasterContent
Expand All @@ -559,7 +586,7 @@ export function TransactionToaster() {
textRevealed={expanded}
/>
</div>
</motion.button>
</motion.div>
) : null}
</AnimatePresence>
</div>
Expand Down
45 changes: 45 additions & 0 deletions src/ui/components/TransactionSigner/Toaster/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,48 @@
transparent 100%
);
}

/* Right-hand actions (copy hash / open explorer) shown in the success state.
Spacing from the text column comes from .body's gap; the negative margin
pulls the icons 8px closer to the pill's right edge, since ghost buttons
carry their own slack around the glyph. */
.actions {
display: flex;
align-items: center;
gap: 4px;
margin-right: -8px;
flex-shrink: 0;
}

/* Ghost: the glyph is the whole button; the 32px box is just the hit area. */
.actionButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--neutral-600);
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}

.actionButton:hover {
color: var(--black);
}

.actionButton:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}

.actionIcon {
display: flex;
align-items: center;
justify-content: center;
}
Loading
Loading