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.
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;dataTransferis unreadable duringdragover, 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
setPointerCapturekeeps the gesture attached to the handle when the pointer leaves it — which is most of a drag. - Render
announcementin a live region. The move is invisible without it, and a silent reorder is the same defect as a keyboardless one. onCommitfires once, with the original and destination indices. The list is yours to mutate; the composable never touches your data.
API
GdSortableOptions
Props
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
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.