Gestures

Solid 2Unstable

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.

Live demo

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.

examples/wishlist/Demo.tsx
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)
}
examples/wishlist/Demo.tsx
// 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()} />
}
examples/wishlist/Client.tsx
"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&apos;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)
}
examples/wishlist/feed.ts
// 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" } })
}
Run Motion's version →