Gestures
Solid 2UnstableA 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.
Shared by everyone who opens this page. You're Heron: open it in a private window too, and rearrange it in both.
Tulip Bowl$92
Faceted Carafe$120
Moon Vase$148
Poppy Plate$54
Blossom Ginger Jar$164
Vole moved the Moon Vase.
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 { 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, Wishlist } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, Reorder, reorderGroup, wardic } from "@wardic/solid/unstable/v2"
import { action, createEffect, createMemo, createOptimistic, createSignal, For, Loading, Show, until, untrack } from "solid-js"
import { visitorName } from "~/lib/store.ts"
import { moveInSharedWishlist, resetSharedWishlist } from "~/server/store.ts"
import { sharedWishlistFeed } from "~/server/wishlist.ts"
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const
export default function SharedWishlist() {
// The list as the store has it, live: its order now, then each move anyone makes, as it's made.
// One call of the live source, read through one memo: the server's render shows its first value,
// and the browser goes on with the feed.
const source = sharedWishlistFeed()
const server = createMemo(() => source)
const [problem, setProblem] = createSignal<string>()
const me = createMemo(() => visitorName())
// The order the list shows when nothing is held: the store's, or my drop's while it's on its way.
// An optimistic value: it goes back to the store's by itself once the move has landed.
const [shown, setShown] = createOptimistic<ReadonlyArray<string>>(() => server().items)
// The order my hands have made of the list (the group's `onReorder`, by pointer or by keys), and
// the piece in my hand: from a hold's start to its end.
const [mine, setMine] = createSignal<ReadonlyArray<string>>()
const [held, setHeld] = createSignal<string>()
// Yours stays yours: while I hold a piece, dragged or lifted with the keys, the list is everyone
// else's order, kept live, with my piece where my hands have it. Another visitor's move ends a
// keyboard lift there (a group drops a lift when its values change), and saves it where it is.
const order = () => {
const local = mine() ?? shown()
const id = held()
if (id === undefined) return local
return withHeldAt(server().items, local, id)
}
// 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 feed has it.
const move = action(function* (id: string, values: ReadonlyArray<string>) {
setShown(values)
setProblem(undefined)
const saved: Wishlist = yield moveInSharedWishlist(id, pieceBefore(values, id))
yield until(() => server().version >= saved.version)
})
// The list: its Reorder group made here and given to `Reorder.Group` (#349), beside what hears it,
// under the page's `<Loading>`, as its order reads the store. The group's holds and reorders, each
// heard by an effect through `when` (#355): a hold's start puts the piece in my hand, a drag's or a
// keyboard lift's; a reorder that changed the order is a move to save, with the piece and the order
// it left; the hold's end, after it, lets the piece go.
const List = () => {
const list = reorderGroup({ values: order, onReorder: setMine })
createEffect(list.when("holdStart"), (start) => {
setMine(untrack(order))
setHeld(start.value)
})
createEffect(list.when("reorderEnd"), (end) => {
move(end.value, end.values).catch((error: unknown) => setProblem(messageOf(error)))
})
createEffect(list.when("holdEnd"), () => {
setMine(undefined)
setHeld(undefined)
})
return (
<Reorder.Group class="wish-list" groupRef={list}>
<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>
)
}
const productOf = (id: string): Product | undefined => PRODUCTS.find((product) => product.id === id)
const nameOf = (id: string) => {
const product = productOf(id)
if (product === undefined) return id
return product.name
}
const who = (name: string) => {
if (name === me()) return "You"
return name
}
return (
<div class="wishlist">
<header class="wishlist-head">
<p class="muted">
Shared by everyone who opens this page.{" "}
<Loading>
You're <strong>{me()}</strong>:
</Loading>{" "}
open it in a private window too, and rearrange it in both.
</p>
<button class="button quiet" onClick={() => resetSharedWishlist()}>
Put it back
</button>
</header>
<Loading fallback={<p class="muted">Loading the wishlist…</p>}>
<List />
</Loading>
<Loading>
<div class="wishlist-foot">
<AnimatePresence initial={false}>
<Show when={server().lastMove} keyed>
{(last) => (
<wardic.p class="moved-by muted" {...fade}>
{who(last.by)} moved the {nameOf(last.productId)}.
</wardic.p>
)}
</Show>
</AnimatePresence>
<Show when={problem()}>
{(message) => (
<p class="error-line" role="alert">
{message()}
</p>
)}
</Show>
</div>
</Loading>
</div>
)
}
/**
* `server`'s order, with `id` just after the piece above it in `local`, or first if none is: `local`
* itself when that's the same order. A keyboard lift ends when the group's values change to any
* other array than the one it handed out (ADR-0037), so an unchanged order must be the same array.
*/
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)
const merged = placedAfter(others, above, id)
if (sameOrder(merged, local)) return local
return merged
}
/** `others` with `id` just after `above`, or first without one. */
function placedAfter(others: ReadonlyArray<string>, above: string | undefined, id: string): ReadonlyArray<string> {
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] as string
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
// 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" } })
}