sortableselection
Sortable
Drag-and-drop reordering with withSortable. Grab any
item to reorder tasks. Items shift out of the way as you
drag — like iOS. Switch between free drag and handle-only.
Tasks
0%
0.00 /
0.00
px/ms
0 /
0
items
0
moves
free
Source
// Sortable — Drag-and-drop reordering
// Demonstrates withSortable plugin with configurable drag handles
import { vlist, withSortable, withSelection } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
// =============================================================================
// Constants
// =============================================================================
const ITEM_HEIGHT = 56;
// =============================================================================
// Data — Task list
// =============================================================================
const PRIORITIES = ["low", "medium", "high", "urgent"];
const PRIORITY_COLORS = {
low: "#94a3b8",
medium: "#f59e0b",
high: "#f97316",
urgent: "#ef4444",
};
const PRIORITY_LABELS = {
low: "Low",
medium: "Medium",
high: "High",
urgent: "Urgent",
};
const TASK_NAMES = [
"Review pull request #42",
"Update API documentation",
"Fix login page redirect",
"Design new onboarding flow",
"Refactor auth middleware",
"Add unit tests for parser",
"Deploy staging environment",
"Update dependency versions",
"Create migration script",
"Set up monitoring alerts",
"Implement rate limiting",
"Fix mobile layout issues",
"Add search functionality",
"Optimize database queries",
"Write integration tests",
"Update error handling",
"Configure CI pipeline",
"Review security audit",
"Implement caching layer",
"Add analytics tracking",
"Fix timezone handling",
"Create admin dashboard",
"Update email templates",
"Add export to CSV",
"Implement webhooks",
"Fix pagination bug",
"Add dark mode support",
"Optimize image loading",
"Create backup strategy",
"Set up load balancer",
];
const ASSIGNEES = [
"Alice Chen",
"Bob Park",
"Carol Liu",
"Dan Kim",
"Eva Jones",
"Frank Lee",
];
function makeTasks(count) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: TASK_NAMES[i % TASK_NAMES.length],
priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)],
assignee: ASSIGNEES[Math.floor(Math.random() * ASSIGNEES.length)],
done: Math.random() < 0.15,
}));
}
let tasks = makeTasks(90);
// =============================================================================
// State — exported for controls
// =============================================================================
export let list = null;
export let useHandle = false;
export let moveCount = 0;
export function setUseHandle(v) {
useHandle = v;
}
// =============================================================================
// Template
// =============================================================================
const itemTemplate = (item) => {
const pc = PRIORITY_COLORS[item.priority];
const pl = PRIORITY_LABELS[item.priority];
const doneClass = item.done ? " task--done" : "";
const initials = item.assignee
.split(" ")
.map((w) => w[0])
.join("");
return `
<div class="task${doneClass}">
<span class="task__handle" aria-label="Drag to reorder">⠿</span>
<span class="task__check">${item.done ? "✓" : ""}</span>
<div class="task__info">
<span class="task__name">${item.name}</span>
<span class="task__meta">
<span class="task__priority" style="color:${pc}" title="${pl}">${pl}</span>
<span class="task__sep">·</span>
<span class="task__assignee">${initials}</span>
</span>
</div>
<span class="task__id">#${item.id}</span>
</div>
`;
};
// =============================================================================
// Stats
// =============================================================================
export const stats = createStats({
getScrollPosition: () => list?.getScrollPosition() ?? 0,
getTotal: () => tasks.length,
getItemSize: () => ITEM_HEIGHT,
getContainerSize: () =>
document.querySelector("#list-container")?.clientHeight ?? 0,
});
const updateInfo = createInfoUpdater(stats);
// =============================================================================
// Create / Recreate list
// =============================================================================
export function createList() {
if (list) {
list.destroy();
list = null;
}
const container = document.getElementById("list-container");
container.innerHTML = "";
const builder = vlist({
container: "#list-container",
ariaLabel: "Task list",
item: {
height: ITEM_HEIGHT,
template: itemTemplate,
},
items: tasks,
});
const sortableConfig = {};
if (useHandle) sortableConfig.handle = ".task__handle";
builder.use(withSortable(sortableConfig));
builder.use(withSelection({ mode: "single" }));
list = builder.build();
// Wire events
list.on("scroll", updateInfo);
list.on("range:change", updateInfo);
list.on("velocity:change", ({ velocity }) => {
stats.onVelocity(velocity);
updateInfo();
});
// Track sort method and original grab position
let sortMethod = "drag";
let grabIndex = -1;
list.on("sort:start", ({ index }) => {
// Pointer drags create a ghost; keyboard grabs don't.
// If no ghost exists on next microtask, it's keyboard.
sortMethod = "drag";
grabIndex = index;
queueMicrotask(() => {
if (!document.querySelector(".vlist-sort-ghost")) sortMethod = "keyboard";
setSortState(sortMethod === "drag" ? "Dragging…" : "Grabbed", true);
});
setControlsDisabled(true);
// In free mode, hide the handle in the ghost
if (!useHandle) {
const ghost = document.querySelector(".vlist-sort-ghost");
if (ghost) ghost.classList.add("vlist-sort-ghost--free");
}
});
// Live position update during pointer drag
list.on("sort:move", ({ fromIndex, currentIndex }) => {
const item = tasks[fromIndex];
if (!item) return;
showLastMove({
name: item.name,
from: fromIndex,
to: currentIndex,
total: tasks.length,
method: "drag",
});
});
list.on("sort:end", ({ fromIndex, toIndex }) => {
// Reorder the data array
const reordered = [...tasks];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
tasks = reordered;
list.setItems(tasks);
moveCount++;
updateMoveCount();
// For keyboard, show cumulative journey from original grab position
const displayFrom = sortMethod === "keyboard" ? grabIndex : fromIndex;
showLastMove({
name: moved.name,
from: displayFrom,
to: toIndex,
total: tasks.length,
method: sortMethod,
});
// Re-enable controls after sort completes.
// Check on next microtask — cleanup runs right after the event.
queueMicrotask(() => {
if (!list.isSorting()) {
setSortState("Idle", false);
setControlsDisabled(false);
}
});
});
list.on("sort:cancel", ({ originalItems }) => {
tasks = originalItems;
list.setItems(tasks);
setSortState("Idle", false);
setControlsDisabled(false);
});
// Keyboard drop doesn't emit a dedicated event. The sortable feature
// blocks keydown via stopImmediatePropagation, so we listen on keyup
// instead — it fires after the drop has been processed.
container.addEventListener("keyup", (e) => {
if (e.key === " " || e.key === "Enter" || e.key === "Escape") {
queueMicrotask(() => {
if (!list.isSorting()) {
setSortState("Idle", false);
setControlsDisabled(false);
}
});
}
});
updateInfo();
updateContext();
}
// =============================================================================
// UI updates
// =============================================================================
const stateEl = document.getElementById("sort-state");
const moveEl = document.getElementById("sort-move");
const moveCountEl = document.getElementById("info-moves");
function setSortState(label, active) {
if (!stateEl) return;
stateEl.innerHTML =
`<span class="sort-status__dot sort-status__dot--${active ? "active" : "idle"}"></span>${label}`;
}
function showLastMove({ name, from, to, total, method }) {
if (!moveEl) return;
const delta = to - from;
const absDelta = Math.abs(delta);
const arrow = delta < 0 ? "↑" : "↓";
const deltaClass = delta < 0 ? "up" : "down";
const plural = absDelta === 1 ? "position" : "positions";
moveEl.className = "sort-status__move sort-status__move--visible";
moveEl.innerHTML =
`<span class="sort-status__name">${name}</span>` +
`<span class="sort-status__detail">` +
`<span class="sort-status__positions">#${from + 1} → #${to + 1} <span class="sort-status__method">of ${total}</span></span>` +
`<span class="sort-status__delta sort-status__delta--${deltaClass}">${arrow} ${absDelta} ${plural}</span>` +
`<span class="sort-status__method">${method}</span>` +
`</span>`;
}
function updateMoveCount() {
if (moveCountEl) moveCountEl.textContent = moveCount;
}
// Disable/enable controls during sort
function setControlsDisabled(disabled) {
const btns = document.querySelectorAll(
".split-panel .ui-btn, .split-panel .ui-segmented__btn",
);
for (const btn of btns) {
if (disabled) btn.setAttribute("disabled", "");
else btn.removeAttribute("disabled");
}
}
// Footer context
const infoHandle = document.getElementById("info-handle");
export function updateContext() {
if (infoHandle) infoHandle.textContent = useHandle ? "handle" : "free";
const container = document.getElementById("list-container");
if (container) {
container.setAttribute("data-grip", useHandle ? "handle" : "free");
}
}
// =============================================================================
// Controls
// =============================================================================
// Handle mode toggle
const handleMode = document.getElementById("handle-mode");
if (handleMode) {
handleMode.addEventListener("click", (e) => {
const btn = e.target.closest("[data-handle]");
if (!btn) return;
const mode = btn.dataset.handle === "true";
if (mode === useHandle) return;
useHandle = mode;
handleMode.querySelectorAll("button").forEach((b) => {
b.classList.toggle(
"ui-segmented__btn--active",
(b.dataset.handle === "true") === mode,
);
});
createList();
});
}
// Add task
const addBtn = document.getElementById("btn-add");
if (addBtn) {
addBtn.addEventListener("click", () => {
const newTask = makeTasks(1)[0];
newTask.id = tasks.length + 1;
newTask.name = TASK_NAMES[tasks.length % TASK_NAMES.length];
tasks = [newTask, ...tasks];
list.setItems(tasks);
updateInfo();
});
}
// Shuffle
const shuffleBtn = document.getElementById("btn-shuffle");
if (shuffleBtn) {
shuffleBtn.addEventListener("click", () => {
tasks = [...tasks].sort(() => Math.random() - 0.5);
list.setItems(tasks);
});
}
// Reset
const resetBtn = document.getElementById("btn-reset");
if (resetBtn) {
resetBtn.addEventListener("click", () => {
tasks = makeTasks(60);
moveCount = 0;
updateMoveCount();
setSortState("Idle", false);
if (moveEl) {
moveEl.className = "sort-status__move";
moveEl.innerHTML = "";
}
list.setItems(tasks);
updateInfo();
});
}
// =============================================================================
// Initialise
// =============================================================================
createList();
/* Sortable — example styles */
/* ============================================================================
vlist overrides
============================================================================ */
#list-container .vlist-item {
padding: 0;
border-bottom: none;
cursor: default;
}
/* Free mode: entire item is draggable */
#list-container[data-grip="free"] .vlist-item {
cursor: grab;
}
#list-container .vlist--sorting .vlist-item {
transition: transform 0.15s ease;
}
/* Grabbing cursor on items during drag */
#list-container .vlist--sorting .vlist-item {
cursor: grabbing;
}
/* ============================================================================
Task Item (56px)
============================================================================ */
.task {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
height: 100%;
width: 100%;
}
.task--done .task__name {
text-decoration: line-through;
opacity: 0.5;
}
/* ── Drag Handle ── */
.task__handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 32px;
font-size: 16px;
color: var(--text-dim, #9ca3af);
cursor: grab;
user-select: none;
flex-shrink: 0;
border-radius: 4px;
transition:
color 0.15s ease,
background 0.15s ease;
}
.task__handle:hover {
color: var(--text-muted, #6b7280);
}
.task__handle:active {
cursor: grabbing;
}
/* Hide handle in free-drag mode */
#list-container:not([data-grip="handle"]) .task__handle {
display: none;
}
/* ── Checkbox ── */
.task__check {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--border, #d1d5db);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--accent, #667eea);
flex-shrink: 0;
}
.task--done .task__check {
background: var(--accent, #667eea);
border-color: var(--accent, #667eea);
color: white;
}
/* ── Info ── */
.task__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.task__name {
font-size: 14px;
font-weight: 500;
color: var(--text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.task__priority {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.task__sep {
color: var(--text-dim, #9ca3af);
}
.task__assignee {
color: var(--text-muted, #6b7280);
font-weight: 500;
}
.task__id {
font-size: 12px;
color: var(--text-dim, #9ca3af);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* ============================================================================
Sort Ghost (dragged item clone)
============================================================================ */
.vlist-sort-ghost {
border-radius: 8px;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.15),
0 2px 8px rgba(0, 0, 0, 0.08);
background: var(--bg, white);
opacity: 0.95 !important;
}
/* Hide handle in ghost when in free-drag mode */
.vlist-sort-ghost.vlist-sort-ghost--free .task__handle {
display: none;
}
/* ============================================================================
Sort Placeholder (drop target gap)
============================================================================ */
.vlist-sort-placeholder {
background: var(--accent, #667eea);
opacity: 0.12;
border-radius: 6px;
margin: 0 8px;
transition: height 0.15s ease;
}
/* ============================================================================
Sorting state — dim non-dragged items slightly
============================================================================ */
.vlist--sorting .vlist-item {
opacity: 0.85;
}
/* ============================================================================
Sort status message
============================================================================ */
.sort-status {
font-size: 12px;
color: var(--text-muted, #6b7280);
line-height: 1.4;
display: flex;
flex-direction: column;
gap: 8px;
}
/* ── Live state line ── */
.sort-status__state {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12px;
color: var(--text, #111827);
}
.sort-status__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.sort-status__dot--idle {
background: var(--text-dim, #9ca3af);
}
.sort-status__dot--active {
background: #3b82f6;
box-shadow: 0 0 6px rgba(59, 130, 246, 0.5);
animation: sort-pulse 1.5s ease-in-out infinite;
}
@keyframes sort-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Last move breakdown ── */
.sort-status__move {
display: none;
}
.sort-status__move--visible {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 6px;
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.08));
}
.sort-status__name {
font-size: 12px;
font-weight: 500;
color: var(--text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sort-status__detail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 11px;
color: var(--text-dim, #9ca3af);
}
.sort-status__positions {
font-variant-numeric: tabular-nums;
}
.sort-status__delta {
font-weight: 600;
font-size: 11px;
}
.sort-status__delta--up {
color: #3b82f6;
}
.sort-status__delta--down {
color: #f59e0b;
}
.sort-status__method {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
/* ============================================================================
Disable controls during sort
============================================================================ */
.split-panel .ui-btn[disabled],
.split-panel .ui-segmented__btn[disabled] {
opacity: 0.4;
pointer-events: none;
}
<div class="container">
<header>
<h1>Sortable</h1>
<p class="description">
Drag-and-drop reordering with <code>withSortable</code>. Grab any
item to reorder tasks. Items shift out of the way as you
drag — like iOS. Switch between free drag and handle-only.
</p>
</header>
<div class="split-layout">
<div class="split-main">
<h2 class="sr-only">Tasks</h2>
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Drag Mode -->
<section class="ui-section">
<h3 class="ui-title">Grip</h3>
<div class="ui-row">
<div class="ui-segmented" id="handle-mode">
<button
class="ui-segmented__btn ui-segmented__btn--active"
data-handle="false"
>
Free
</button>
<button class="ui-segmented__btn" data-handle="true">
Handle
</button>
</div>
</div>
</section>
<!-- Status -->
<section class="ui-section">
<h3 class="ui-title">Status</h3>
<div class="ui-card ui-card--compact sort-status" id="sort-status">
<span class="sort-status__state" id="sort-state">
<span class="sort-status__dot sort-status__dot--idle"></span>
Idle
</span>
<div class="sort-status__move" id="sort-move"></div>
</div>
</section>
<!-- Keyboard -->
<section class="ui-section">
<h3 class="ui-title">Keyboard</h3>
<div class="ui-row">
<p class="ui-hint">
<kbd>Space</kbd> grab · <kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Space</kbd> drop · <kbd>Esc</kbd> cancel
</p>
</div>
</section>
<!-- Actions -->
<section class="ui-section">
<h3 class="ui-title">Actions</h3>
<div class="ui-row">
<div class="ui-btn-group">
<button id="btn-add" class="ui-btn" title="Add task">
+ Add
</button>
<button
id="btn-shuffle"
class="ui-btn"
title="Shuffle"
>
Shuffle
</button>
<button id="btn-reset" class="ui-btn" title="Reset">
Reset
</button>
</div>
</div>
</section>
</aside>
</div>
<div class="example-info" id="example-info">
<div class="example-info__left">
<span class="example-info__stat">
<strong id="info-progress">0%</strong>
</span>
<span class="example-info__stat">
<span id="info-velocity">0.00</span> /
<strong id="info-velocity-avg">0.00</strong>
<span class="example-info__unit">px/ms</span>
</span>
<span class="example-info__stat">
<span id="info-dom">0</span> /
<strong id="info-total">0</strong>
<span class="example-info__unit">items</span>
</span>
</div>
<div class="example-info__right">
<span class="example-info__stat">
<strong id="info-moves">0</strong>
<span class="example-info__unit">moves</span>
</span>
<span class="example-info__stat">
<strong id="info-handle">free</strong>
</span>
</div>
</div>
</div>