Sortable list
A list you can reorder by dragging or by keyboard, with a line showing where the item will land. Reach for it when the order is the data — map layers, a checklist, a report's sections.
Reordering a list
Code
<GdSortableList
:items="layers"
:item-key="l => l.id"
:item-label="l => l.name"
label="Map layers"
@move="(from, to) => project.moveLayer(from, to)"
>
<template #default="{ item }">
<GdListRow :title="item.name" :sub="item.meta" />
</template>
</GdSortableList>The drop indicator
A dragged item follows the pointer, which tells you what you are holding. It does not tell you what will happen when you let go, and between the second and third row is not something a floating card can express.
The terminal dot is not decoration. A bare line is ambiguous at the ends of a list and inside nested ones — a rule between two rows could belong to either, and at a boundary it is unclear whether the item lands inside the group above or after it. The dot marks the line's origin, and that is what disambiguates it.
API
GdSortableList
Props
itemsrequiredreadonly unknown[]—Rendered, never mutated. The component reports a move and stops — see Usage.
itemKeyrequired(item, index) => string | number—A stable key per item. An index is not one: it changes the moment the list reorders, which is exactly when Vue needs it to be stable.
itemLabel(item, index) => string—The item's name, used in the spoken announcements. Without it they say "Item 3", which is true and useless.
orientation"vertical" | "horizontal""vertical"Which axis the list runs along. Decides the arrow keys as well as the line.
labelstring"Sortable list"The accessible name for the list.
indicatorInsetnumber0Indents the drop line, so a nested level's line aligns with its own rows rather than the container.
disabledbooleanfalseHides the handles. The list still renders and stays readable.
Slots
defaultScoped, receives { item, index }. The row content.
handleScoped, receives { item, index }. Replaces the default grip glyph.
Events
move(from: number, to: number)Emitted once, on drop. Not emitted when the item lands where it started.
GdDropIndicator
Props
orientation"vertical" | "horizontal""vertical"The axis the LIST runs along; the line is drawn across it.
edge"before" | "after""before"Which side of the host item the line sits on.
insetnumber0How far in from the leading edge the line starts.
useGdSortable
The gesture and the keyboard, without the markup — for a list whose rows you draw yourself. Takes count, itemAt and onMove; returns dragging, overIndex, edge, announcement and handleProps(i).
Usage Guidelines
- Reach for it when the ORDER is the data. Map layers stack in the order you see; a checklist runs in the order you work. A list sorted by name or date is not this — give that a column header.
- Handle the move yourself. The component reports
@move(from, to)and does not touch your array. That is deliberate: only you know whether the move needs a history entry, a save, or a rollback. - Give it
itemLabel. Without one every announcement says "Item 3", which is true and tells a screen-reader user nothing about what they just moved. - Keep the handle separate from the row's own controls. The default already does. A row that is draggable in its entirety cannot be clicked, and any button inside it stops working.
- Do not use it to drag between lists. That is a different problem with different affordances — see the note in Why.
Behavior & Anatomy
Pointer events, not HTML5 drag and drop
The native API is the obvious choice and the wrong one here. It does not exist on touch — there is no counterpart to dragover, so a native implementation is desktop-only and silently so. dataTransfer is deliberately unreadable during dragover, so the thing you need in order to decide where the indicator goes is the one thing the API withholds until it is too late to draw it. And the drag image belongs to the browser, which disagrees with other browsers about it.
Pointer Events give one code path for mouse, touch and pen, and setPointerCapture keeps the gesture attached to the handle once the pointer leaves it — which is most of a drag. The one thing native buys that this does not is dragging between windows or out to the operating system, and a list reorder never needs it.
The keyboard is not an extra
A reorder that only works by pointer is a feature some people cannot use at all. The model is grab → move → drop, with Escape to cancel, rather than "arrows move it immediately": the immediate version cannot be cancelled, and it announces on every keypress, so moving an item five places is five interruptions saying almost the same thing.
The dragged row stays where it is
It dims rather than lifting out under a floating clone. The clone is showier and costs the thing that matters: with the row gone, the list closes the gap, and every subsequent measurement is against geometry that moved because of the drag. Left in place, the indicator's arithmetic stays stable — which is what prevents the drop-one-off bug that every hand-rolled reorder has.
For the same reason the indicator is absolutely positioned and costs no height. An indicator that takes up space pushes the row you are aiming at away from the pointer at the exact moment you commit.
Four pixels before a drag begins
A drag does not start on pointerdown. Without a threshold every click on the grip is a zero-length drag, and a handle that also opens a menu or toggles a row stops working. Four pixels is small enough that a real drag feels immediate and large enough to survive the tremor in a click.
touch-action: none on the handle is the half everyone forgets: without it the browser claims the gesture for scrolling before any handler sees it, and the drag never starts on a touchscreen.
One list, reordering itself
Dragging between lists, onto a target, or out to the OS are three different problems with different affordances and different failure modes. Pretending one component does all four is how a drag layer becomes the thing nobody will touch. If a second container is genuinely needed, it deserves its own component and its own page.