// Ranking: renumber the ranks after every move, disable the arrow at either end, and let the
// Move up / Move down buttons reorder a row in one click (announced through the same live region).
document.addEventListener('dd:change', e => {
const root = e.target; if (!root.matches('[data-ranking]')) return;
const rows = [...root.querySelectorAll('[data-dd-item]')];
rows.forEach((li, i) => {
li.querySelector('[data-rank]').textContent = i + 1;
li.querySelector('[data-step="-1"]').setAttribute('aria-disabled', i === 0);
li.querySelector('[data-step="1"]').setAttribute('aria-disabled', i === rows.length - 1);
});
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-step]'); if (!b || b.getAttribute('aria-disabled') === 'true') return;
const li = b.closest('[data-dd-item]'), root = li.closest('[data-dd]'), up = b.dataset.step === '-1';
const sib = up ? li.previousElementSibling : li.nextElementSibling; if (!sib) return;
up ? sib.before(li) : sib.after(li); b.focus();
root.dispatchEvent(new Event('dd:change', { bubbles: true }));
const live = root.querySelector('[data-dd-live]'), n = [...li.parentElement.children].indexOf(li) + 1;
live.textContent = li.querySelector('[data-dd-name]').textContent + ' moved to rank ' + n + ' of ' + li.parentElement.children.length + '.';
});
// 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 patterns that go beyond a flat list. Nested outline: sections and pages is a documentation sidebar with three sections, each holding its own pages. Drag a whole section to reorder sections, or drag a single page into another section; each section header counts its pages and an emptied section offers a Drop pages here slot. Sections and pages belong to separate groups, so a section can never be dropped inside another section or into its own pages. Priority ranking with numbers asks what matters most in a laptop and lists six criteria with rank numbers that are rewritten after every move, the top three highlighted. Each row also has Move up and Move down buttons, so anyone can rank with plain clicks or taps without dragging at all. Use the outline for navigation and menu builders, course outlines and file trees, and the ranking for surveys, feature voting and preference forms. From the keyboard, Up and Down move a section or page, and Left and Right move a page to the previous or next section.
What’s included
Two-level outline: sections and pages reorder separately
Pages move between sections with pointer or Left and Right keys
Page counts per section and an empty-section slot
Rank numbers rewritten after every move
Move up and Move down buttons as a no-drag alternative
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 "Forms and validation moved to Reference, position 1 of 3.". The ranking also has Move up and Move down buttons that disable themselves at either end with aria-disabled. 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 --tr-accent and --tr-tint on .tr, the .tr-pages indent for the tree, and the :nth-child(-n+3) rule to highlight more or fewer top ranks. Bootstrap uses .btn-group for the arrows; Tailwind uses the tr colours in @theme.