Animate orders out with a CSS transition instead of the View Transitions API
The prior attempt wrapped the sign-out router.invalidate() in document.startViewTransition(), but that had two problems: invalidate() does its own network refetch inside the transition's update callback, which freezes the page for the round-trip instead of animating anything, and TanStack Router's invalidate() explicitly opts itself out of view transitions (sets shouldViewTransition = false) for exactly that reason. On top of that, OrdersRoute briefly returns null between the account loader clearing and the guest-orders lookup resolving, unmounting OrdersListView and wiping any exit-animation state anyway. Replace it with a plain, self-contained approach: - OrdersRoute now keeps rendering the last known non-null orders list through that brief gap (a lastKnownOrders ref) instead of unmounting OrdersListView, so its local animation state survives. - OrdersListView holds onto the previous non-empty list for EXIT_DURATION_MS after it goes empty, applying an `orderExiting` class that fades/slides each card out via a plain CSS transition, then swaps to the actual empty state once the transition finishes. This has no dependency on browser view-transition support, network timing, or router internals. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,13 +71,21 @@
|
|||||||
background: $color-surface;
|
background: $color-surface;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: border-color 0.15s ease;
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
opacity 0.28s ease,
|
||||||
|
transform 0.28s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: $color-primary;
|
border-color: $color-primary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orderExiting .order {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
.orderHeader {
|
.orderHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
||||||
import { formatPrice } from '#/lib/format.ts'
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
@@ -9,6 +10,10 @@ type OrdersListViewProps = {
|
|||||||
orders: Array<CustomerOrderSummary>
|
orders: Array<CustomerOrderSummary>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must match the CSS transition duration on .order in
|
||||||
|
// OrdersListView.module.scss.
|
||||||
|
const EXIT_DURATION_MS = 280
|
||||||
|
|
||||||
const OrdersIcon = () => (
|
const OrdersIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -32,10 +37,32 @@ const formatItems = (items: CustomerOrderSummary['items']) =>
|
|||||||
)
|
)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
export const OrdersListView = ({ orders }: OrdersListViewProps) => {
|
||||||
|
const [displayedOrders, setDisplayedOrders] = useState(orders)
|
||||||
|
const [isExiting, setIsExiting] = useState(false)
|
||||||
|
const hadOrders = useRef(orders.length > 0)
|
||||||
|
|
||||||
|
// Orders can go from populated to empty in one prop update (e.g. signing
|
||||||
|
// out clears them) — keep the previous list on screen just long enough to
|
||||||
|
// fade it out instead of popping straight to the empty state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (orders.length === 0 && hadOrders.current) {
|
||||||
|
hadOrders.current = false
|
||||||
|
setIsExiting(true)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setDisplayedOrders([])
|
||||||
|
setIsExiting(false)
|
||||||
|
}, EXIT_DURATION_MS)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
hadOrders.current = orders.length > 0
|
||||||
|
setDisplayedOrders(orders)
|
||||||
|
}, [orders])
|
||||||
|
|
||||||
|
return (
|
||||||
<section className={styles.page}>
|
<section className={styles.page}>
|
||||||
<h1 className={styles.heading}>Your orders</h1>
|
<h1 className={styles.heading}>Your orders</h1>
|
||||||
{orders.length === 0 ? (
|
{displayedOrders.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<OrdersIcon />
|
<OrdersIcon />
|
||||||
<p className={styles.emptyTitle}>No orders yet</p>
|
<p className={styles.emptyTitle}>No orders yet</p>
|
||||||
@@ -48,8 +75,11 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className={styles.orders}>
|
<ul className={styles.orders}>
|
||||||
{orders.map((order) => (
|
{displayedOrders.map((order) => (
|
||||||
<li key={order.id}>
|
<li
|
||||||
|
key={order.id}
|
||||||
|
className={isExiting ? styles.orderExiting : undefined}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/orders/$orderId"
|
to="/orders/$orderId"
|
||||||
params={{ orderId: String(order.id) }}
|
params={{ orderId: String(order.id) }}
|
||||||
@@ -65,7 +95,9 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
|||||||
<span className={styles.orderName}>Order #{order.id}</span>
|
<span className={styles.orderName}>Order #{order.id}</span>
|
||||||
<span className={styles.orderStatus}>{order.status}</span>
|
<span className={styles.orderStatus}>{order.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.orderItems}>{formatItems(order.items)}</p>
|
<p className={styles.orderItems}>
|
||||||
|
{formatItems(order.items)}
|
||||||
|
</p>
|
||||||
<p className={styles.orderMeta}>
|
<p className={styles.orderMeta}>
|
||||||
{order.createdAt.toLocaleDateString([], {
|
{order.createdAt.toLocaleDateString([], {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -80,3 +112,4 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,27 +153,7 @@ const BetterAuthHeader = () => {
|
|||||||
clearBasket()
|
clearBasket()
|
||||||
void authClient.signOut({
|
void authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => void router.invalidate(),
|
||||||
// Cross-fades the now-cleared Orders list out (and
|
|
||||||
// the empty state in) instead of popping instantly —
|
|
||||||
// each order card already carries its own
|
|
||||||
// `view-transition-name` (see OrdersListView), so
|
|
||||||
// this reuses that for a proper per-card exit.
|
|
||||||
if ('startViewTransition' in document) {
|
|
||||||
// Rejects if the browser can't actually run the
|
|
||||||
// transition right now (e.g. the tab isn't
|
|
||||||
// visible) — router.invalidate() still runs either
|
|
||||||
// way, so there's nothing to handle beyond not
|
|
||||||
// leaving this as an unhandled rejection.
|
|
||||||
const transition = document.startViewTransition(() =>
|
|
||||||
router.invalidate(),
|
|
||||||
)
|
|
||||||
transition.ready.catch(() => {})
|
|
||||||
transition.finished.catch(() => {})
|
|
||||||
} else {
|
|
||||||
void router.invalidate()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { OrdersListView } from '#/components/orders/OrdersListView.tsx'
|
import { OrdersListView } from '#/components/orders/OrdersListView.tsx'
|
||||||
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
||||||
@@ -25,7 +25,16 @@ const OrdersRoute = () => {
|
|||||||
void getGuestOrders({ data: { refs } }).then(setGuestOrders)
|
void getGuestOrders({ data: { refs } }).then(setGuestOrders)
|
||||||
}, [accountOrders])
|
}, [accountOrders])
|
||||||
|
|
||||||
const orders = accountOrders ?? guestOrders
|
const resolvedOrders = accountOrders ?? guestOrders
|
||||||
|
|
||||||
|
// Signing out clears both sources for a moment (the account loader
|
||||||
|
// reruns and comes back null, guest refs were just wiped) before the
|
||||||
|
// guest lookup above resolves to `[]` — keep showing the last known
|
||||||
|
// list through that gap instead of unmounting OrdersListView, so its
|
||||||
|
// own exit animation (see OrdersListView) can actually play.
|
||||||
|
const lastKnownOrders = useRef<Array<CustomerOrderSummary> | null>(null)
|
||||||
|
if (resolvedOrders !== null) lastKnownOrders.current = resolvedOrders
|
||||||
|
const orders = resolvedOrders ?? lastKnownOrders.current
|
||||||
if (orders === null) return null
|
if (orders === null) return null
|
||||||
|
|
||||||
return <OrdersListView orders={orders} />
|
return <OrdersListView orders={orders} />
|
||||||
|
|||||||
Reference in New Issue
Block a user