Gestures
A wishlist you rearrange by hand
Drag a piece up or down and the others make way; let go and the new order is saved. On Solid 2 the list is shared, and other people's moves glide in live.
Yours alone: rearrange it, then reload the page and it's as you left it.
Loading the wishlist…
Reduced motion: The piece you hold still follows your hand; the others move to their new places at once, and so does the drop.
Why
Rearranging a list should feel like moving things on a table: the piece follows your hand, the others slide out of its way, and when you let go it settles into the gap. Each piece is a Reorder.Item in a Reorder.Group. The group changes the order as the piece you hold passes its neighbours, and every other piece animates to its new place.
The order is saved when you let go, once per drag, as "put it after this one": the store applies that to its order as it is then, so it means the same whatever else changed meanwhile.
On Solid 2 the list is shared by every visitor. The page arrives with the order as it was when the server rendered it, then follows a live() server function, which sends the order again each time anyone moves a piece. Other people's moves glide into place while you watch. While you hold a piece, dragged or lifted with the keys, theirs move around it, but yours stays in your hand, and your drop shows at once while it's saved. The group tells each hold's start and end, and each reorder, and the page acts on each through an effect over list.when(name): holdStart, reorderEnd and holdEnd. The group is made with reorderGroup() beside those reads, and given to Reorder.Group through groupRef.
The code
Motion's Reorder.Group tells each step of a drag (onReorder), not its start or end, so its list holds the piece in your hand from each item's onDragStart to its onDragEnd, and saves from onDragEnd when the order changed. Wardic's group tells holdStart, onReorderEnd (once a gesture, only when the order changed) and holdEnd, for a keyboard lift too: Motion's Reorder has no keys. And Next.js has no live server function: Motion's list is shared through a route handler that streams the store's changes as server-sent events, which the page reads with an EventSource, where Solid 2 has live(). Solid 1's list is the visitor's own.
import { createAsync, useAction, useSubmission } from "@solidjs/router"
import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, Reorder, wardic } from "@wardic/solid"
import { createSignal, For, Show, Suspense } from "solid-js"
import { moveInWishlist, resetWishlist, wishlist } from "~/lib/store.ts"
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const
export default function MyWishlist() {
const saved = createAsync(() => wishlist())
const move = useAction(moveInWishlist)
const reset = useAction(resetWishlist)
const moving = useSubmission(moveInWishlist)
// The order my hand has made, from the drag's first step until the store has saved my drop; the
// store's order otherwise.
const [mine, setMine] = createSignal<ReadonlyArray<string>>()
const order = (): ReadonlyArray<string> => {
const local = mine()
if (local !== undefined) return local
const list = saved()
if (list === undefined) return []
return list.items
}
// A drop is a move: "put it after this one", saved once a drag, as the drag ends.
const save = (id: string, values: ReadonlyArray<string>) => {
move(id, pieceBefore(values, id))
.catch(() => undefined)
.finally(() => setMine(undefined))
}
const putBack = () => {
setMine(undefined)
reset().catch(() => undefined)
}
const productOf = (id: string): Product | undefined => PRODUCTS.find((product) => product.id === id)
return (
<div class="wishlist">
<header class="wishlist-head">
<p class="muted">Yours alone: rearrange it, then reload the page and it's as you left it.</p>
<button class="button quiet" onClick={putBack}>
Put it back
</button>
</header>
<Suspense fallback={<p class="muted">Loading the wishlist…</p>}>
<Show when={saved()}>
<Reorder.Group class="wish-list" values={order()} onReorder={setMine} onReorderEnd={(end) => save(end.value, end.values)}>
<For each={order()}>
{(id) => (
<Reorder.Item class="wish" value={id} data-id={id}>
<img src={photoOf(id).small} width="56" height="56" alt="" draggable={false} />
<Show when={productOf(id)}>
{(product) => (
<span class="wish-text">
<strong>{product().name}</strong>
<span class="muted">{formatPrice(product().price)}</span>
</span>
)}
</Show>
<span class="grip" aria-hidden="true">
⋮⋮
</span>
</Reorder.Item>
)}
</For>
</Reorder.Group>
</Show>
</Suspense>
<div class="wishlist-foot">
<AnimatePresence initial={false}>
<Show when={moving.pending}>
<wardic.p class="moved-by muted" {...fade}>
Saving…
</wardic.p>
</Show>
</AnimatePresence>
<Show when={moving.error}>
{(error) => (
<p class="error-line" role="alert">
{String((error() as Error).message)}
</p>
)}
</Show>
</div>
</div>
)
}
/** The piece just before `id` in `values`, or `null` when it's first. */
function pieceBefore(values: ReadonlyArray<string>, id: string): string | null {
const index = values.indexOf(id)
if (index <= 0) return null
return values[index - 1] as string
}
// The shared wishlist, a server component: it starts the list's first read and the visitor's name,
// and hands their promises to the client part, which follows the list live from there.
import { getSharedWishlist, getVisitorName } from "~/lib/store.ts"
import { SharedWishlist } from "./Client.tsx"
export default function Demo() {
return <SharedWishlist first={getSharedWishlist()} me={getVisitorName()} />
}
"use client"
import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Wishlist } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion, Reorder } from "motion/react"
import { Suspense, startTransition, use, useEffect, useOptimistic, useRef, useState } from "react"
import { moveInSharedWishlist, resetSharedWishlist } from "~/lib/store.ts"
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const
interface Props {
readonly first: Promise<Wishlist>
readonly me: Promise<string>
}
export function SharedWishlist({ first, me }: Props) {
return (
<div className="wishlist">
<header className="wishlist-head">
<p className="muted">
Shared by everyone who opens this page.{" "}
<Suspense>
<Me me={me} />
</Suspense>{" "}
open it in a private window too, and rearrange it in both.
</p>
<button className="button quiet" onClick={() => resetSharedWishlist()}>
Put it back
</button>
</header>
<Suspense fallback={<p className="muted">Loading the wishlist…</p>}>
<List first={first} me={me} />
</Suspense>
</div>
)
}
function Me({ me }: { readonly me: Promise<string> }) {
return (
<>
You're <strong>{use(me)}</strong>:
</>
)
}
function List(props: Props) {
const me = use(props.me)
// The list as the store has it, live: the server's first read, then each move anyone makes, as
// it's made, from the feed. An older version never replaces a newer one.
const [server, setServer] = useState(use(props.first))
useEffect(() => {
const feed = new EventSource("/api/wishlist")
feed.onmessage = (event: MessageEvent<string>) => {
const next = JSON.parse(event.data) as Wishlist
setServer((now) => newer(now, next))
}
return () => feed.close()
}, [])
const [problem, setProblem] = useState<string>()
// The order the list shows when nothing is held: the store's, or my drop's while it's on its way.
// It goes back to the store's by itself once the move's transition ends.
const [shown, setShown] = useOptimistic(server.items)
// The order my hand has made of the list (the group's `onReorder`), and the piece in my hand: from
// a drag's start to its end. Motion's group tells each step of a drag, not its start or end: each
// item's own drag callbacks do.
const [mine, setMine] = useState<ReadonlyArray<string>>()
const [held, setHeld] = useState<string>()
const before = useRef<ReadonlyArray<string>>([])
// Yours stays yours: while I hold a piece, the list is everyone else's order, kept live, with my
// piece where my hand has it.
const local = mine ?? shown
const order = held === undefined ? local : withHeldAt(server.items, local, held)
// A drop is a move: "put it after this one", which the store applies to its order as it is then,
// so it means the same whatever others moved meanwhile. My order shows until the store's has it.
const save = (id: string, values: ReadonlyArray<string>) =>
startTransition(async () => {
setShown(values)
setProblem(undefined)
try {
const saved = await moveInSharedWishlist(id, pieceBefore(values, id))
startTransition(() => setServer((now) => newer(now, saved)))
} catch (error) {
setProblem(messageOf(error))
}
})
return (
<>
<Reorder.Group as="ul" axis="y" className="wish-list" values={order as Array<string>} onReorder={setMine}>
{order.map((id) => {
const product = PRODUCTS.find((one) => one.id === id)
return (
<Reorder.Item
key={id}
value={id}
className="wish"
data-id={id}
onDragStart={() => {
before.current = order
setMine(order)
setHeld(id)
}}
onDragEnd={() => {
setMine(undefined)
setHeld(undefined)
// Saved once a drag, at its end, and only when it changed the order.
if (!sameOrder(order, before.current)) save(id, order)
}}
>
<img src={photoOf(id).small} width="56" height="56" alt="" draggable={false} />
{product && (
<span className="wish-text">
<strong>{product.name}</strong>
<span className="muted">{formatPrice(product.price)}</span>
</span>
)}
<span className="grip" aria-hidden="true">
⋮⋮
</span>
</Reorder.Item>
)
})}
</Reorder.Group>
<div className="wishlist-foot">
<AnimatePresence initial={false}>
{server.lastMove && (
<motion.p key={server.version} className="moved-by muted" {...fade}>
{server.lastMove.by === me ? "You" : server.lastMove.by} moved the {nameOf(server.lastMove.productId)}.
</motion.p>
)}
</AnimatePresence>
{problem && (
<p className="error-line" role="alert">
{problem}
</p>
)}
</div>
</>
)
}
/** Of two orders of the list, the newer. */
function newer(now: Wishlist, next: Wishlist): Wishlist {
return next.version > now.version ? next : now
}
function nameOf(id: string): string {
return PRODUCTS.find((product) => product.id === id)?.name ?? id
}
/** `server`'s order, with `id` just after the piece above it in `local`, or first if none is. */
function withHeldAt(server: ReadonlyArray<string>, local: ReadonlyArray<string>, id: string): ReadonlyArray<string> {
const others = server.filter((one) => one !== id)
const above = local
.slice(0, local.indexOf(id))
.filter((one) => others.includes(one))
.at(-1)
if (above === undefined) return [id, ...others]
const at = others.indexOf(above) + 1
return [...others.slice(0, at), id, ...others.slice(at)]
}
function sameOrder(a: ReadonlyArray<string>, b: ReadonlyArray<string>): boolean {
return a.length === b.length && a.every((one, i) => one === b[i])
}
/** The piece just before `id` in `values`, or `null` when it's first. */
function pieceBefore(values: ReadonlyArray<string>, id: string): string | null {
const index = values.indexOf(id)
if (index <= 0) return null
return values[index - 1]!
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
// The shared wishlist, live: Next.js has no live server function, so the list's changes come over
// server-sent events, from a route handler (`app/api/wishlist/route.ts` serves this `GET`). The
// store's feed sends its order now, then each change as anyone makes it; it ends when the reader
// leaves.
import * as Store from "@wardic/example-storefront-shared/server"
import { visitor } from "~/lib/visitor.ts"
export async function GET() {
const changes = (await Store.streamFor(await visitor(), Store.wishlistChanges("shared")))[Symbol.asyncIterator]()
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
const next = await changes.next()
if (next.done) controller.close()
else controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`))
},
async cancel() {
await changes.return?.()
},
})
// `no-transform`: a compressing server would hold the events back.
return new Response(body, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform" } })
}