Layout
Order history in a scrolling panel
Sort your orders and each one glides to its new place; open one and only the orders below it make room.
Your orders
Loading your orders…
Reduced motion: Orders re-sort, open and close at once; their pieces still fade.
Why
A list that re-sorts in a blink leaves you hunting for the order you were looking at. When each one glides to its new place, your eye stays on it. And a panel that scrolls inside a page that scrolls too is where layout animations usually go wrong: measure a row before the panel scrolled and after, and the scroll itself looks like movement.
Each order is a layout element. A re-sort moves the orders without resizing any, and nothing else tells them they've moved, so the sort goes through animateLayout: every layout element animates from where it was at the call. Opening an order changes its height, which it sees itself, so only the rows below it move; its summary is layout="position", so the text travels without stretching.
Wardic takes the scroll of every scrolling element around a layout element out of what it measures, so a row's box is the same however far the panel has scrolled, with nothing to mark on the panel.
The code
Motion measures every layout element each time React renders it, so a re-sort needs nothing but the new order. Solid doesn't render a list again to move its rows, so Wardic is told about a change that moves elements without resizing any: wrap it in animateLayout, whose promise settles once they've all landed. Motion tells each row's layout animation starting and ending, so its list counts the rows on their way to know when it's done. And Motion needs layoutScroll on the scrolling panel, or a row opened lower down moves by however far the panel has scrolled; Wardic finds the scrolling containers itself.
import { createAsync } from "@solidjs/router"
import { formatDay, formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Order, Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, useAnimateLayout, wardic } from "@wardic/solid"
import { createSignal, For, Show, Suspense } from "solid-js"
import { orders, products } from "~/lib/store.ts"
// An order's pieces fade in once its row has started to open, and out quickly as it closes.
const reveal = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { type: "tween", duration: 0.2, delay: 0.1 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
const SORTS = { newest: "Newest first", oldest: "Oldest first", total: "Highest total" } as const
type Sort = keyof typeof SORTS
const SORT_KEYS = Object.keys(SORTS) as ReadonlyArray<Sort>
/** `orders` in `sort`'s order: they come newest first. */
function sortedBy(orders: ReadonlyArray<Order>, sort: Sort): ReadonlyArray<Order> {
if (sort === "oldest") return [...orders].reverse()
if (sort === "total") return [...orders].sort((a, b) => b.total - a.total)
return orders
}
export default function OrderHistory() {
const history = createAsync(() => orders())
const all = createAsync(() => products())
const [open, setOpen] = createSignal<string>()
const [sort, setSort] = createSignal<Sort>("newest")
const animateLayout = useAnimateLayout()
// A re-sort moves the orders without resizing any, and nothing else tells them they've moved:
// declare it, so each one animates from where it is now, inside the scrolled panel too.
const sortBy = (next: Sort) => animateLayout(() => setSort(next))
const toggle = (id: string) => {
if (open() === id) setOpen(undefined)
else setOpen(id)
}
const productOf = (id: string): Product | undefined => {
const list = all()
if (list === undefined) return undefined
return list.find((product) => product.id === id)
}
return (
<section class="orders-panel" aria-label="Your orders">
<header class="orders-head">
<h2>Your orders</h2>
<label class="sort">
<span>Sort</span>
<select value={sort()} onChange={(event) => sortBy(event.currentTarget.value as Sort)}>
<For each={SORT_KEYS}>{(key) => <option value={key}>{SORTS[key]}</option>}</For>
</select>
</label>
</header>
<Suspense fallback={<p class="muted">Loading your orders…</p>}>
{/* A scrolling panel: Wardic measures each row where it's drawn, however far the panel has
scrolled, so opening one lower down moves the rows below it, and nothing else. */}
<Show when={history()}>
{(list) => (
<ul class="orders">
<For each={sortedBy(list(), sort())}>
{(order) => (
<wardic.li class="order" layout>
<wardic.button class="order-summary" layout="position" aria-expanded={open() === order.id} onClick={() => toggle(order.id)}>
<span class="order-id">{order.id}</span>
<span class="order-day">{formatDay(order.placedOn)}</span>
<span class="status" data-status={order.status}>
{order.status}
</span>
<span class="order-total">{formatPrice(order.total)}</span>
</wardic.button>
<AnimatePresence initial={false} mode="popLayout">
<Show when={open() === order.id}>
<wardic.ul class="order-lines" layout="position" {...reveal}>
<For each={order.lines}>{(line) => <OrderLine line={line} product={productOf(line.productId)} />}</For>
</wardic.ul>
</Show>
</AnimatePresence>
</wardic.li>
)}
</For>
</ul>
)}
</Show>
</Suspense>
</section>
)
}
function OrderLine(props: { readonly line: Order["lines"][number]; readonly product: Product | undefined }) {
return (
<li>
<img src={photoOf(props.line.productId).small} width="40" height="40" alt="" />
<Show when={props.product}>{(product) => <span>{product().name}</span>}</Show>
<span class="muted">× {props.line.quantity}</span>
</li>
)
}
// Order history, a server component: it starts the two reads and hands their promises to the
// client part, which shows them as they arrive.
import { getOrders, listProducts } from "~/lib/store.ts"
import { OrderHistory } from "./Client.tsx"
export default function Demo() {
return <OrderHistory orders={getOrders()} products={listProducts()} />
}
"use client"
import { formatDay, formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Order, Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion } from "motion/react"
import { Suspense, use, useRef, useState } from "react"
// An order's pieces fade in once its row has started to open, and out quickly as it closes.
const reveal = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { type: "tween", duration: 0.2, delay: 0.1 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
const SORTS = { newest: "Newest first", oldest: "Oldest first", total: "Highest total" } as const
type Sort = keyof typeof SORTS
const SORT_KEYS = Object.keys(SORTS) as ReadonlyArray<Sort>
/** `orders` in `sort`'s order: they come newest first. */
function sortedBy(orders: ReadonlyArray<Order>, sort: Sort): ReadonlyArray<Order> {
if (sort === "oldest") return [...orders].reverse()
if (sort === "total") return [...orders].sort((a, b) => b.total - a.total)
return orders
}
interface Props {
readonly orders: Promise<ReadonlyArray<Order>>
readonly products: Promise<ReadonlyArray<Product>>
}
export function OrderHistory({ orders, products }: Props) {
const [sort, setSort] = useState<Sort>("newest")
// The list is busy from a re-sort until its rows have landed.
const [sorting, setSorting] = useState(false)
const sortBy = (next: Sort) => {
if (next === sort) return
setSorting(true)
// A re-sort is only the new order: Motion measures every layout element each time React
// renders it, so the rows animate from where they were.
setSort(next)
}
return (
<section className="orders-panel" aria-label="Your orders">
<header className="orders-head">
<h2>Your orders</h2>
<label className="sort">
<span>Sort</span>
<select value={sort} onChange={(event) => sortBy(event.currentTarget.value as Sort)}>
{SORT_KEYS.map((key) => (
<option key={key} value={key}>
{SORTS[key]}
</option>
))}
</select>
</label>
</header>
<Suspense fallback={<p className="muted">Loading your orders…</p>}>
<Orders orders={orders} products={products} sort={sort} sorting={sorting} landed={() => setSorting(false)} />
</Suspense>
</section>
)
}
function Orders({ sort, sorting, landed, ...props }: Props & { readonly sort: Sort; readonly sorting: boolean; readonly landed: () => void }) {
const orders = use(props.orders)
const products = use(props.products)
const [open, setOpen] = useState<string | null>(null)
// Motion tells each row's layout animation starting and ending, not the whole change's: count
// the rows on their way, and the list has landed when none is left.
const moving = useRef(new Set<string>())
return (
// A scrolling panel: `layoutScroll` has Motion take its scroll out of what it measures, or a
// row opened lower down would move by however far the panel has scrolled.
<motion.ul className="orders" layoutScroll aria-busy={sorting}>
{sortedBy(orders, sort).map((order) => (
<motion.li
key={order.id}
className="order"
layout
onLayoutAnimationStart={() => moving.current.add(order.id)}
onLayoutAnimationComplete={() => {
moving.current.delete(order.id)
if (moving.current.size === 0) landed()
}}
>
<motion.button className="order-summary" layout="position" aria-expanded={open === order.id} onClick={() => setOpen(open === order.id ? null : order.id)}>
<span className="order-id">{order.id}</span>
<span className="order-day">{formatDay(order.placedOn)}</span>
<span className="status" data-status={order.status}>
{order.status}
</span>
<span className="order-total">{formatPrice(order.total)}</span>
</motion.button>
<AnimatePresence initial={false} mode="popLayout">
{open === order.id && (
<motion.ul key="lines" className="order-lines" layout="position" {...reveal}>
{order.lines.map((line) => (
<OrderLine key={line.productId} line={line} product={products.find((product) => product.id === line.productId)} />
))}
</motion.ul>
)}
</AnimatePresence>
</motion.li>
))}
</motion.ul>
)
}
function OrderLine({ line, product }: { readonly line: Order["lines"][number]; readonly product: Product | undefined }) {
return (
<li>
<img src={photoOf(line.productId).small} width="40" height="40" alt="" />
{product && <span>{product.name}</span>}
<span className="muted">× {line.quantity}</span>
</li>
)
}