# sortable

> Drag-and-drop reordering for lists and cross-container boards with the sortable plugin, the clay:sorted event, and DOM child-order persistence.

Source: https://clayjs.com/docs/plugins/sortable/

The `sortable` plugin adds drag-and-drop reordering to lists and multi-column boards in [edit mode](https://clayjs.com/docs/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](https://clayjs.com/docs/script-tag/):

```html
<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](https://clayjs.com/docs/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. |

```html
<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:

```js
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.

```js
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](https://clayjs.com/docs/regions/), 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](#the-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.

```js
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](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live) describes how a participant is prepared, and [Leaving edit](https://clayjs.com/docs/guide/edit-mode/#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.

> [!NOTE]
> 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`](https://clayjs.com/docs/api/) 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`](#moving-an-item-with-code) rather than reordering nodes by hand. Each real move is one [undo](https://clayjs.com/docs/plugins/undo/) entry, the same as a drag, so do not wrap it in `clay.undo.commit`.

> [!WARNING]
> 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 with `Sortable.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.
> - **`onsorting` cannot veto a move.** Its return value is discarded. To cancel moves conditionally, configure `Sortable.get(container).option("onMove", (evt) => false)` in JavaScript.
