// Sprint planner: total the story points in the sprint list and compare them with its capacity.
document.addEventListener('dd:change', e => {
const root = e.target, list = root.querySelector('[data-capacity]'); if (!list) return;
const pts = [...list.querySelectorAll('[data-dd-item]')].reduce((s, li) => s + +li.dataset.points, 0), cap = +list.dataset.capacity;
root.querySelector('[data-sp-points]').textContent = pts;
root.querySelector('[data-sp-bar]').style.width = Math.min(100, pts / cap * 100) + '%';
root.querySelector('[data-sp-note]').textContent = pts > cap ? 'Over capacity by ' + (pts - cap) + (pts - cap === 1 ? ' point' : ' points') : (cap - pts) + ' points free';
root.toggleAttribute('data-over-capacity', pts > cap);
});
// 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
Drag items into targets rather than reordering one list. Assign people to teams starts with three unassigned people as pills with initials and roles, and three team boxes for Design, Engineering and Research, each with a limit of three, four or two people. Drop a person on a team and its count goes up; drop them on a full team and the box turns red and refuses. Plan a sprint against its capacity has a backlog and a sprint list of user stories with story points; as stories move, a bar and a line of text show how many of the twenty points are used and warn when the sprint is over capacity. Use them for assignment screens, seating plans, shopping and order builders, and planning tools. Every target is a list in the same group, and data-dd-max sets its limit. A whole team box, header included, takes the drop, so a small target is still easy to hit, and empty teams show a Drop people here slot. From the keyboard, Left and Right move a person or story to the previous or next target, and a full target is announced instead of being entered.
What’s included
People and stories move between several targets
Per-target limits with a red refusal state
Live counts, story point total and capacity bar
Whole team boxes act as drop targets
Left and Right keys carry an item to the next target
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 "Felix Wu moved to Design, position 1 of 3.". 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 --as-accent, --as-tint and --as-danger on .as; data-dd-max on a list sets its limit and data-capacity on the sprint list sets the points budget. Bootstrap uses .card, .badge and .progress; Tailwind uses the as colours in @theme.