Skip to content
Composables

useGdSortable

Reorder a list by pointer and by keyboard, with the same result from both. Pointer Events rather than HTML5 drag-and-drop, because that API does not exist on touch and withholds what you need to draw a drop indicator until it is too late.

Anatomy

See GdSortableList for the live specimen — it is this composable with the list, the handles and the drop indicator already drawn.

GdSortableList is this composable with a list around it. Drag a handle, or focus one and press Space then the arrow keys — the two paths commit identically.
Code
const { handleProps, announcement } = useGdSortable({
  count: () => items.length,
  elementAt: (i) => rows.value[i],
  onCommit: (from, to) => move(from, to),
  labelAt: (i) => items[i].label
})

Two paths, one result

  • The keyboard is not an extra. A reorder that works only by pointer is a feature some people cannot use at all. The model is GRAB → MOVE → DROP, with Escape to cancel.
  • Not HTML5 drag-and-drop. It has no touch counterpart to dragover, so a native implementation is desktop-only and silently so; dataTransfer is unreadable during dragover, so the thing you need to place the drop line is withheld until too late; and the drag image is the browser's, which browsers disagree about.
  • Pointer Events give one code path for mouse, touch and pen, and setPointerCapture keeps the gesture attached to the handle when the pointer leaves it — which is most of a drag.
  • Render announcement in a live region. The move is invisible without it, and a silent reorder is the same defect as a keyboardless one.
  • onCommit fires once, with the original and destination indices. The list is yours to mutate; the composable never touches your data.

API

GdSortableOptions

Props

PropTypeDefault
countrequired() => number—

How many items there are, read live — a list can grow mid-drag.

elementAtrequired(index: number) => HTMLElement | undefined—

The element for an index, so geometry can be measured without a ref array per item.

onCommitrequired(from: number, to: number) => void—

Commit. Called once, with the ORIGINAL index and the destination.

orientation"vertical" | "horizontal""vertical"

Which axis the list runs along.

labelAt(index: number) => string—

What to say — a name for the item at this index, used in the live announcements.

scroller() => HTMLElement | undefined—

The scrolling ancestor, for edge auto-scroll. Defaults to the nearest one.

Returns

Props

PropTypeDefault
draggingRef<number | null>—

Index being dragged, or null.

overIndexRef<number | null>—

Index the pointer is currently over.

edgeRef<"before" | "after" | null>—

Which side of `overIndex` the drop line sits on.

grabbedRef<boolean>—

True during a KEYBOARD grab — the Space-to-grab, arrows-to-move mode.

destinationRef<number | null>—

Where a commit would land right now.

announcementRef<string>—

The live-region text. Render it in a polite region so a screen-reader user hears the move.

handleProps(index: number) => object—

Spread onto each drag handle: pointer handlers, `tabindex`, `role`, `aria-pressed` and `touch-action: none`.

cancel() => void—

Abandon the gesture and put everything back.

Navigate

Esc