Sortable
Drag the rows of a list, a table or a grid into a new order, or move them with the arrow keys. A thin layer over SortableJS.
A handle drags its row
Put sortable on the element that holds the rows. A
.sortable-handle inside a row is the only place that
drags it, and it is a button, so the arrow keys move its row too.
Size
Material
Price
<div class="item-group sortable" id="so-main" style="max-width: 28rem">
<article class="item" data-variant="outline" data-id="color">
<button type="button" class="sortable-handle" aria-label="Reorder Color"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Color</h3></section>
</article>
<article class="item" data-variant="outline" data-id="size">
<button type="button" class="sortable-handle" aria-label="Reorder Size"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Size</h3></section>
</article>
<article class="item" data-variant="outline" data-id="material">
<button type="button" class="sortable-handle" aria-label="Reorder Material"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Material</h3></section>
</article>
<article class="item" data-variant="outline" data-id="price">
<button type="button" class="sortable-handle" aria-label="Reorder Price"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Price</h3></section>
</article>
</div>
A page loads SortableJS itself, before the Tsunami script
The drag is
SortableJS
1.15.7 (MIT licence). It is not in tsunami.min.js: a
page that has a .sortable loads
dist/sortable.min.js with its own
<script> first. A page with no sortable list
does not load it and is not affected. With the script missing,
each .sortable logs an error and stays a plain list.
<script src="https://cdn.jsdelivr.net/gh/Djirasak/tsunami-framework@v0.1.3/dist/sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/Djirasak/tsunami-framework@v0.1.3/dist/tsunami.min.js"></script>
A list with no handle is dragged by its rows
Without a handle there are no keys: the rows move by pointer only.
-
Home
-
Shop
-
Support
<ul class="item-group sortable" style="max-width: 28rem">
<li class="item" data-variant="outline" data-id="home"><section><h3>Home</h3></section></li>
<li class="item" data-variant="outline" data-id="shop"><section><h3>Shop</h3></section></li>
<li class="item" data-variant="outline" data-id="support"><section><h3>Support</h3></section></li>
</ul>
On a tbody, the rows of a table are reordered
| Label | Source | |
|---|---|---|
| Color | Product attribute | |
| Availability | Standard | |
| Price | Standard |
<div class="card scroll-area">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1px" aria-label="Reorder"></th>
<th scope="col">Label</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody class="sortable" id="so-table">
<tr data-id="color">
<td style="width: 1px"><button type="button" class="sortable-handle" aria-label="Reorder Color"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button></td>
<td>Color</td>
<td>Product attribute</td>
</tr>
<tr data-id="availability">
<td style="width: 1px"><button type="button" class="sortable-handle" aria-label="Reorder Availability"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button></td>
<td>Availability</td>
<td>Standard</td>
</tr>
<tr data-id="price">
<td style="width: 1px"><button type="button" class="sortable-handle" aria-label="Reorder Price"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button></td>
<td>Price</td>
<td>Standard</td>
</tr>
</tbody>
</table>
</div>
data-sortable-ignore on a control keeps a press on it
from starting a drag
In a list with no handle, a button or a link would otherwise start a drag. Its own click still happens.
-
Visa
-
PayPal
-
Klarna
<ul class="item-group sortable" style="max-width: 28rem">
<li class="item" data-variant="outline" data-id="visa">
<section><h3>Visa</h3></section>
<aside><button type="button" class="btn" data-variant="outline" data-size="sm" data-sortable-ignore>Edit</button></aside>
</li>
<li class="item" data-variant="outline" data-id="paypal">
<section><h3>PayPal</h3></section>
<aside><button type="button" class="btn" data-variant="outline" data-size="sm" data-sortable-ignore>Edit</button></aside>
</li>
<li class="item" data-variant="outline" data-id="klarna">
<section><h3>Klarna</h3></section>
<aside><button type="button" class="btn" data-variant="outline" data-size="sm" data-sortable-ignore>Edit</button></aside>
</li>
</ul>
data-disabled keeps the rows where they are
The rows stay put by pointer and by key, and the handles are dimmed.
Standard
Express
<div class="item-group sortable" style="max-width: 28rem" data-disabled>
<article class="item" data-variant="outline" data-id="standard">
<button type="button" class="sortable-handle" aria-label="Reorder Standard"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Standard</h3></section>
</article>
<article class="item" data-variant="outline" data-id="express">
<button type="button" class="sortable-handle" aria-label="Reorder Express"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Express</h3></section>
</article>
</div>
data-variant="overlay" lays the handle over a
picture: it shows under the pointer
The arrow keys move a picture along the grid, left and right as well as up and down.
<ul class="sortable" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); max-width: 28rem">
<li style="position: relative" data-id="watch">
<img src="img/product-watch.svg" alt="" style="display: block; width: 100%; aspect-ratio: 1; border: 1px solid var(--line); border-radius: var(--radius-md); object-fit: cover" />
<button type="button" class="sortable-handle" data-variant="overlay" aria-label="Reorder watch"><i class="icon icon-move" aria-hidden="true"></i></button>
</li>
<li style="position: relative" data-id="mug">
<img src="img/product-mug.svg" alt="" style="display: block; width: 100%; aspect-ratio: 1; border: 1px solid var(--line); border-radius: var(--radius-md); object-fit: cover" />
<button type="button" class="sortable-handle" data-variant="overlay" aria-label="Reorder mug"><i class="icon icon-move" aria-hidden="true"></i></button>
</li>
<li style="position: relative" data-id="headphones">
<img src="img/product-headphones.svg" alt="" style="display: block; width: 100%; aspect-ratio: 1; border: 1px solid var(--line); border-radius: var(--radius-md); object-fit: cover" />
<button type="button" class="sortable-handle" data-variant="overlay" aria-label="Reorder headphones"><i class="icon icon-move" aria-hidden="true"></i></button>
</li>
</ul>
data-options takes any SortableJS option as JSON:
here a 600ms animation
The options are laid over the component's own (animation 150,
forceFallback true). Under
prefers-reduced-motion the default animation is 0, so
set it here only when you mean it.
Monday
Tuesday
Wednesday
<div class="item-group sortable" style="max-width: 28rem" data-options='{"animation":600}'>
<article class="item" data-variant="outline" data-id="monday">
<button type="button" class="sortable-handle" aria-label="Reorder Monday"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Monday</h3></section>
</article>
<article class="item" data-variant="outline" data-id="tuesday">
<button type="button" class="sortable-handle" aria-label="Reorder Tuesday"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Tuesday</h3></section>
</article>
<article class="item" data-variant="outline" data-id="wednesday">
<button type="button" class="sortable-handle" aria-label="Reorder Wednesday"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Wednesday</h3></section>
</article>
</div>
The tsunami:reorder event gives the row and its old
and new place, and toArray() the new order
Phone
Address
Move a row.
<div style="display: grid; gap: var(--space-3); max-width: 28rem">
<div class="item-group sortable" id="so-event">
<article class="item" data-variant="outline" data-id="email">
<button type="button" class="sortable-handle" aria-label="Reorder Email"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Email</h3></section>
</article>
<article class="item" data-variant="outline" data-id="phone">
<button type="button" class="sortable-handle" aria-label="Reorder Phone"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Phone</h3></section>
</article>
<article class="item" data-variant="outline" data-id="address">
<button type="button" class="sortable-handle" aria-label="Reorder Address"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Address</h3></section>
</article>
</div>
<p id="so-event-output">Move a row.</p>
</div>
<script>
document.getElementById("so-event").addEventListener("tsunami:reorder", (event) => {
const { id, oldIndex, newIndex } = event.detail;
document.getElementById("so-event-output").textContent =
`${id} moved from ${oldIndex} to ${newIndex}. Order: ${event.target.toArray().join(", ")}`;
});
</script>
The page reverses the order with toArray() and
sort(), and turns sorting off with
disabled
First
Second
Third
<div style="display: grid; gap: var(--space-3); max-width: 28rem">
<div class="item-group sortable" id="so-methods">
<article class="item" data-variant="outline" data-id="first">
<button type="button" class="sortable-handle" aria-label="Reorder First"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>First</h3></section>
</article>
<article class="item" data-variant="outline" data-id="second">
<button type="button" class="sortable-handle" aria-label="Reorder Second"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Second</h3></section>
</article>
<article class="item" data-variant="outline" data-id="third">
<button type="button" class="sortable-handle" aria-label="Reorder Third"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Third</h3></section>
</article>
</div>
<div style="display: flex; gap: var(--space-2)">
<button type="button" class="btn" data-size="sm" id="so-reverse">Reverse the order</button>
<button type="button" class="btn" data-size="sm" id="so-toggle" aria-pressed="false">Turn sorting off</button>
</div>
</div>
<script>
const methods = document.getElementById("so-methods");
document.getElementById("so-reverse").addEventListener("click", () => {
methods.sort(methods.toArray().reverse());
});
document.getElementById("so-toggle").addEventListener("click", (event) => {
methods.disabled = !methods.disabled;
event.target.setAttribute("aria-pressed", methods.disabled);
event.target.textContent = methods.disabled ? "Turn sorting on" : "Turn sorting off";
});
</script>
A list started by mount() takes callbacks:
onMove keeps the pinned row first,
onEnd reports the drop
Pinned, always first
Orders
Customers
Products
Drag a row.
<div style="display: grid; gap: var(--space-3); max-width: 28rem">
<div class="item-group" id="so-mount">
<article class="item" data-variant="outline" data-id="pinned">
<i class="icon icon-pin" aria-hidden="true" style="width: 1.5rem; color: var(--muted)"></i>
<section><h3>Pinned, always first</h3></section>
</article>
<article class="item" data-variant="outline" data-id="orders" data-movable>
<button type="button" class="sortable-handle" aria-label="Reorder Orders"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Orders</h3></section>
</article>
<article class="item" data-variant="outline" data-id="customers" data-movable>
<button type="button" class="sortable-handle" aria-label="Reorder Customers"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Customers</h3></section>
</article>
<article class="item" data-variant="outline" data-id="products" data-movable>
<button type="button" class="sortable-handle" aria-label="Reorder Products"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Products</h3></section>
</article>
</div>
<p id="so-mount-output">Drag a row.</p>
</div>
<script>
const mounted = document.getElementById("so-mount");
const output = document.getElementById("so-mount-output");
tsunami.sortable.mount(mounted, {
// Only the marked rows move.
draggable: "[data-movable]",
// Called while a row is over another one. Returning false refuses that place.
onMove: (event) => event.related.hasAttribute("data-movable"),
// Called when the row is let go, moved or not.
onEnd: (event) => {
output.textContent = `onEnd: ${event.item.dataset.id} went from ${event.oldDraggableIndex} to ${event.newDraggableIndex}`;
},
});
</script>
sortable is the SortableJS instance: here the page
changes one of its options
Draft
Review
Published
<div style="display: grid; gap: var(--space-3); max-width: 28rem">
<div class="item-group sortable" id="so-instance">
<article class="item" data-variant="outline" data-id="draft">
<button type="button" class="sortable-handle" aria-label="Reorder Draft"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Draft</h3></section>
</article>
<article class="item" data-variant="outline" data-id="review">
<button type="button" class="sortable-handle" aria-label="Reorder Review"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Review</h3></section>
</article>
<article class="item" data-variant="outline" data-id="published">
<button type="button" class="sortable-handle" aria-label="Reorder Published"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Published</h3></section>
</article>
</div>
<button type="button" class="btn" data-size="sm" id="so-instance-button" style="justify-self: start">Set the animation to 600ms</button>
</div>
<script>
document.getElementById("so-instance-button").addEventListener("click", (event) => {
const list = document.getElementById("so-instance");
list.sortable.option("animation", 600);
event.target.textContent = `Animation is ${list.sortable.option("animation")}ms`;
});
</script>
A page tells the save bar about a new order by writing it to a hidden input
A move sends no input event, because the order is not
a form value. The page keeps the order in a hidden field and calls
tsunami.saveBar.check(form); the bar then shows until
the form is saved.
<form id="so-form" style="display: grid; gap: var(--space-3); max-width: 28rem">
<input type="hidden" name="order" value="first,second,third" />
<div class="item-group sortable" id="so-form-list">
<article class="item" data-variant="outline" data-id="first">
<button type="button" class="sortable-handle" aria-label="Reorder First"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>First</h3></section>
</article>
<article class="item" data-variant="outline" data-id="second">
<button type="button" class="sortable-handle" aria-label="Reorder Second"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Second</h3></section>
</article>
<article class="item" data-variant="outline" data-id="third">
<button type="button" class="sortable-handle" aria-label="Reorder Third"><i class="icon icon-grip-vertical" aria-hidden="true"></i></button>
<section><h3>Third</h3></section>
</article>
</div>
</form>
<div class="save-bar" data-form="so-form" role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="submit" form="so-form" class="btn" data-variant="primary">Save</button>
</div>
</div>
<script>
const form = document.getElementById("so-form");
document.getElementById("so-form-list").addEventListener("tsunami:reorder", (event) => {
form.elements.order.value = event.target.toArray().join(",");
tsunami.saveBar.check(form);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
form.elements.order.defaultValue = form.elements.order.value;
tsunami.saveBar.markClean(form);
});
</script>
A page saves the order itself
The component sends nothing to a server. A page posts the order
from its tsunami:reorder listener.
list.addEventListener("tsunami:reorder", (event) => {
fetch("/filters/order/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order: event.target.toArray() }),
});
});
API
The functions live on window.tsunami.sortable, and
the list element carries the same members.
| Function / member | Description |
|---|---|
tsunami.sortable.mount(el, options?) |
Starts SortableJS on a list, with or without the class,
and starts it again if it already has one.
options are SortableJS options, laid over
data-options, and can hold functions. Throws
when SortableJS is not loaded.
|
tsunami.sortable.get(el) |
The SortableJS instance, or null. |
tsunami.sortable.destroy(el) |
Stops the list and gives the element back. Removing the list from the page does this itself. |
tsunami.sortable.toArray(el) /
el.toArray()
|
Returns every row's data-id, in the current
order.
|
tsunami.sortable.sort(el, order) /
el.sort(order)
|
Puts the rows in the order of a list of
data-id values. It sends no
tsunami:reorder event.
|
tsunami.sortable.disabled(el, value?) /
el.disabled
|
Reads and sets data-disabled. |
el.sortable |
The SortableJS instance, for anything the component does not cover (read only). |
Events
| Event | On | Detail | Description |
|---|---|---|---|
tsunami:reorder |
The list; it bubbles | { item, id, oldIndex, newIndex } |
Sent after a row is moved, by pointer or by key.
item is the row, id its
data-id, and the indexes count rows from 0.
It is not sent for sort() or for a row moved
to another list (SortableJS sends its own
add and remove).
|
Keyboard
| Key | Description |
|---|---|
| Arrow Up, Arrow Left | On a handle, moves its row up (or back) one place. |
| Arrow Down, Arrow Right | On a handle, moves its row down (or forward) one place. |
| Tab | Moves to the next handle. A handle is a button, so this is the browser's own. |
A list with no .sortable-handle is dragged by its
rows, and has no keys.
A move made with the keys is spoken through a live region: the
handle's aria-label, then
moved to position N of M.
SortableJS sends its own events on the same element:
start, end, update and the
rest. The component adds only tsunami:reorder.
Under prefers-reduced-motion the animation is 0. The
drag uses SortableJS's fallback mode (a copy of the row follows
the pointer), which also works on touch screens; the handle has
touch-action: none.
The order is not a form value, so a move sends no
input event. To mark a form changed, write the order
to a hidden input and call
tsunami.saveBar.check(form), as in the example above.
SortableJS
1.15.7 is MIT licensed (vendor/sortable/LICENSE.txt).
It is a separate file, dist/sortable.min.js, not part
of tsunami.min.js.
Attributes
| Attribute / element | Values | Description |
|---|---|---|
.sortable |
On the element that holds the rows: a ul, a
tbody, an item group or a grid. Its children
are the rows. A list inside a row is its own sortable.
|
|
data-id |
text |
On a row. Names the row: toArray() returns it
and the tsunami:reorder event carries it.
|
.sortable-handle |
A button inside a row. With one in the list, only the
handle drags, and the arrow keys move the row. The default
icon is icon-grip-vertical.
|
|
aria-label |
text |
On a .sortable-handle. Names the handle for a
screen reader, for example Reorder Color. A
move made with the keys is spoken with it.
|
data-variant |
overlay |
On a .sortable-handle. Lays the handle over a
picture in a position: relative row; it shows
under the pointer and on focus.
|
data-sortable-ignore |
present | On a control inside a row. A press on it never starts a drag. Its own click still happens. |
data-disabled |
present |
On .sortable. Keeps the rows where they are,
by pointer and by key, and dims the handles. Adding or
removing it later is followed.
|
data-options |
JSON |
On .sortable. Any SortableJS option. It is
laid over the component's own: animation 150
(0 under reduced motion), forceFallback true,
filter [data-sortable-ignore],
handle .sortable-handle when the
list has one.
|
.sortable-ghost,
.sortable-fallback,
.sortable-chosen
|
Added by SortableJS while a row is dragged: the gap where it will land, the copy under the pointer and the row itself. | |
.sortable-status |
Added by the script on first key move: a visually hidden live region that speaks the move. |