// Agenda: rewrite each start time and the end time whenever the order changes.
document.addEventListener('dd:change', e => {
const root = e.target; if (!root.matches('[data-agenda]')) return;
let t = +root.dataset.start; const hhmm = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
root.querySelectorAll('[data-dd-item]').forEach(li => { li.querySelector('[data-time]').textContent = hhmm(t); t += +li.dataset.min; });
root.querySelector('[data-end]').textContent = hhmm(t);
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
const name = el => el.querySelector('[data-dd-name]').textContent.trim();
const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
const pos = el => { const l = el.parentElement, all = items(l);
return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
const changed = () => {
marks();
root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
root.dispatchEvent(new Event('dd:change', { bubbles: true }));
};
let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
root.addEventListener('pointerdown', e => {
const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
if (!item || e.button > 0 || kb) return;
if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
});
root.addEventListener('pointermove', e => {
if (!drag || e.pointerId !== pid) return;
if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
clearOver(list);
if (!list || !root.contains(list) || drag.contains(list)) return;
list.setAttribute('data-over', '');
if (list !== drag.parentElement && full(list)) return;
const over = items(list).find(c => c !== drag && c.contains(hit));
if (over) { const r = over.getBoundingClientRect();
const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
} else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
});
const end = e => {
if (!drag || e.pointerId !== pid) return;
if (ghost) {
ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
changed(); dragged = true; setTimeout(() => { dragged = false; });
}
drag = null;
};
root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
function start() {
const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
}
// Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
root.addEventListener('click', e => {
const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
const item = h.closest('[data-dd-item]');
if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
});
root.addEventListener('keydown', e => {
if (!kb || e.target !== kbh) return;
if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
e.preventDefault();
const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
if (side && ls.length > 1) {
const t = ls[ls.indexOf(l) + d];
if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
return move(() => t.insertBefore(kb, items(t)[i] || null));
}
const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
});
root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
changed();
});
About this set
Two sortable lists for the most common drag and drop job: putting things in order. Task list with handles is a launch checklist where each row has a six-dot grip, a checkbox, a due day, an owner and a coloured tag; drag a row anywhere by the mouse, or by its grip on a touch screen. Agenda with running times is a meeting agenda in which every item has a duration, so the start times and the end time are recalculated each time an item moves. Use the first for to-do lists, playlists and settings that users rank, and the second whenever order changes something else on screen. Both run on one script of under a hundred lines of vanilla JavaScript with no library. Pointer events drive the drag for mouse, touch and pen: the real row stays in the list as a dashed placeholder while a floating copy follows the pointer, and the row is moved in the DOM as you pass the middle of its neighbours. The same script gives every handle a keyboard mode, so nothing depends on the pointer, and each move is spoken by screen readers.
What’s included
Drag rows with mouse, touch or pen through pointer events
Keyboard mode: Space to pick up, arrow keys to move, Escape to cancel
Every move announced in an aria-live region
Dashed placeholder and a lifted floating copy while dragging
Agenda start and end times recalculated on every move
Accessibility
Every handle is a toggle button named Move plus the item name, with aria-pressed and the visible instructions joined through aria-describedby. Space or Enter picks the item up, the arrow keys move it, Space or Enter drops it and Escape puts it back, and each step is announced in an assertive aria-live region, for example "Review the pricing page moved to position 2 of 5.". Handles show a visible focus outline, and the tilt of the dragged copy and any transitions switch off under prefers-reduced-motion.
Customise it
Change --sl-accent, --sl-tint and --sl-line on .sl for colour, and the .sl-row padding for density. In Bootstrap the same variables sit on .sl next to --bs-primary; in Tailwind edit the sl colours in @theme.