- ClayJS
- Reference
- Plugins
- sortable
sortable
Drag-and-drop reordering for lists and cross-container boards with the sortable plugin, the clay:sorted event, and DOM child-order persistence.
The sortable plugin adds drag-and-drop reordering to lists and multi-column boards in edit mode. Reordered items are saved directly in their new DOM child order.
Loading the plugin#
Add sortable to the ?plugins= query parameter on the ClayJS script tag:
<script src="https://clayjs.com/v1/clay.js?plugins=sortable"></script>
sortable is an edit-only plugin. On a document that boots in view and never enters edit it does nothing. On a document with <html clay-mode-switch="live">, entering edit loads the plugin and fetches SortableJS while the tab still shows view; the drags wait for the commit. It bundles SortableJS 1.15.6 as window.Sortable, binds every [sortable] container on the page, and binds any [sortable] container inserted later after a 200 ms debounce.
Attributes#
| Attribute | Where it goes | Behavior |
|---|---|---|
sortable | Container (<ul>, <ol>, <div>, and so on) | Makes direct children draggable (<li> children on <ul> and <ol>, or all direct element children on other tags). Elements inside editor UI regions (clay="editor-ui") are skipped. |
sortable="name" | Container | Sets the shared group name. Containers with the same name accept items dragged between them. |
sortable-handle | Element inside a draggable item | Restricts dragging in that container to the handle element (ignoring handles inside nested [sortable] containers). |
onsorting="code" | Container | Runs code (as an async function body with this set to the container and evt in scope) as an item moves. Its return value is ignored, so it cannot cancel a move. |
onsorted="code" | Container | Runs code (as an async function body with this set to the container and evt in scope) when a drop finishes. |
<div class="columns" sortable="columns">
<section class="column" data-id="col-todo">
<header>
<span class="grip" sortable-handle show-when:editmode="true">⠿</span>
<h2>To do</h2>
</header>
<ol class="cards" sortable="cards">
<li class="card" data-id="c-1">
<span class="grip" sortable-handle show-when:editmode="true">⠿</span>
<span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Write brief</span>
</li>
</ol>
</section>
</div>
The clay:sorted event#
When a drop finishes, the plugin dispatches a bubbling clay:sorted CustomEvent on the container (for a move between two containers, it fires on the source container from).
event.detail contains five properties:
| Property | Type | Description |
|---|---|---|
item | Element | The DOM element that was moved. |
from | Element | The container the item started in. |
to | Element | The container the item was dropped into. |
oldIndex | number | The item’s index in from before the move. |
newIndex | number | The item’s index in to after the move. |
Use a clay:sorted listener to record any durable state change that accompanies a cross-container move, reading item.dataset.id and to rather than numeric indexes:
document.addEventListener("clay:sorted", (event) => {
const { item, from, to } = event.detail;
if (from !== to) {
const column = to.closest(".column");
if (column) item.dataset.column = column.dataset.id;
}
});
Moving an item with code#
clay.sortable.move(item, target, beforeItem) makes the reorder a drag makes. It inserts item into target before beforeItem, or at the end of target when beforeItem is null. It is synchronous, it never starts a drag, and it does not wait for SortableJS.
clay.sortable.move(item, target, beforeItem = null);
// { ok: true, moved: true, item, from, to, oldIndex, newIndex }
// { ok: true, moved: false }
// { ok: false, code: "not-editable" | "detached" | "group" | "anchor" | "item" }
item is an item of a [sortable] container in this document, target is a [sortable] container in the same document, and beforeItem is null or an item of target. Everything is checked before anything moves, and a refusal names the check that failed:
| Result | Meaning |
|---|---|
{ ok: true, moved: true, item, from, to, oldIndex, newIndex } | The item moved. from and to are the containers and oldIndex/newIndex are the element indexes a drop also reports; resolve identity from data-id, never from these indexes. |
{ ok: true, moved: false } | Nothing moved and nothing happened: the item was asked to land before itself, or to take the position it already holds. No event, no undo entry. |
{ ok: false, code: "not-editable" } | The page is not in edit mode, or the edit session has already closed writes because an exit is running. |
{ ok: false, code: "detached" } | item, its container or target is not a connected element of this document. |
{ ok: false, code: "group" } | A move between two different containers that do not carry the same, non-empty sortable group name. |
{ ok: false, code: "anchor" } | beforeItem is not an item of target, for example because a peer removed it or moved it to another list. The call does not guess a neighbour. |
{ ok: false, code: "item" } | item is not an item of a [sortable] container, or target would not take it (wrong child kind, editor UI, a cms-template seed row), or target is the item itself or inside it. |
Undo. A real move, by drag or by clay.sortable.move, is one undo entry, labelled with the list’s sortable-label attribute or Move. A drop that leaves the item in the slot it started in, a drag cancelled with Escape or by an onMove that returns false, and a refused call record nothing, so history never holds a move nobody made. The browser’s own drag bookkeeping (draggable, style on the held item) is never an edit. If another tab changes the source or destination list while a drag is held, the move is not recorded, the card stays where the merged document put it, and the page receives clay:undo-unavailable with detail: { label, code: "conflicted" }.
A real move is a completed reorder, so it runs the source container’s onsorted and dispatches the bubbling clay:sorted event on the source container, exactly as a drop does. A no-op dispatches nothing, so a save listener never fires for it.
When a move is available#
The member arrives with the sortable module, which is edit-only and loads late: it is not there when clay.ready resolves. Wait for clay:plugin-ready and check detail.plugin, and treat clay:plugin-failed with the same name as “this page cannot move anything”. Leave the control disabled until then, and say why when it fails, rather than enabling a button that silently does nothing.
document.addEventListener("clay:plugin-ready", (event) => {
if (event.detail.plugin !== "sortable") return;
const card = document.querySelector('[data-id="col-todo"] > ol.cards > [data-id="c-1"]');
const list = document.querySelector('[data-id="col-done"] > ol.cards');
const result = clay.sortable.move(card, list, null);
if (!result.ok) console.warn("not moved:", result.code);
});
The facade itself is published as soon as the module evaluates, before SortableJS is fetched for the drag editor, so a move control never waits for the vendor the drag waits for. On a page that boots in view and never enters edit the plugin is not loaded, so clay.sortable is undefined there.
Across a mode change#
sortable is a participant of the mode lifecycle, with the id clay:sortable and order 300. Switching without a reload describes how a participant is prepared, and Leaving edit how an exit refuses.
On a live entry the plugin is prepared while the tab still shows view, and the commit that enters edit is what creates the instances, so nothing is draggable until the page is in edit.
On the way out the lease closes admission before the exit’s save barrier. A drag that ends after that records nothing: onsorting does not run, clay:sorted and onsorted do not fire, and no undo entry is recorded. A refused exit hands the same lease back, and dragging works again.
After a committed exit the module stays loaded and clay.sortable stays attached, so hide or disable a move control rather than testing for the member. clay.sortable.move answers { ok: false, code: "not-editable" } once the page is back in view, which is the same answer it gives in edit while an exit is running.
How order is saved#
Order is saved as plain DOM child order in the HTML file. The plugin never writes an order or index attribute onto your elements.
While an item is being dragged, SortableJS temporarily sets draggable="false", inline style attributes, and drag classes (sortable-chosen, sortable-ghost, sortable-drag). The sortable plugin strips those classes and restores the original draggable and style attributes in the snapshot clone and on pointer release, so even a save taken mid-drag never writes drag residue into the file.
If you call window.Sortable.create(...) yourself on a container that does not use the sortable attribute, strip .sortable-chosen, .sortable-ghost, .sortable-drag, draggable="false", and empty style="" attributes in a clay.onSnapshot callback so they do not save into the file.
Keyboard reordering and limitations#
The sortable plugin has no keyboard reordering support. Items can only be reordered by dragging with a mouse, trackpad, or touch pointer. Even if you place aria-label="Drag" on a [sortable-handle] element, the plugin does not attach keyboard listeners (keydown, arrow keys, or tabindex). If your document needs keyboard reordering, wire up your own keyboard shortcut or move buttons that call clay.sortable.move rather than reordering nodes by hand. Each real move is one undo entry, the same as a drag, so do not wrap it in clay.undo.commit.
Keep these three rules in mind when building sortable lists:
- Handle detection runs once at bind time. If a script injects
[sortable-handle]elements after the container is bound, enable handle mode explicitly withSortable.get(container).option("handle", "[sortable-handle]"). - One handle makes the whole container handle-only. If one item in a container has
[sortable-handle], any sibling item without a[sortable-handle]cannot be dragged at all. onsortingcannot veto a move. Its return value is discarded. To cancel moves conditionally, configureSortable.get(container).option("onMove", (evt) => false)in JavaScript.