// Pipeline: add up each column's deal values, and the open total (every column except the last, Won).
document.addEventListener('dd:change', e => {
const root = e.target; if (!root.matches('[data-pipeline]')) return;
const usd = n => '$' + n.toLocaleString('en-US'); let open = 0;
const cols = root.querySelectorAll('[data-dd-list]');
cols.forEach((l, i) => { const sum = [...l.querySelectorAll('[data-dd-item]')].reduce((s, li) => s + +li.dataset.value, 0);
root.querySelector(`[data-sum="${l.id}"]`).textContent = usd(sum); if (i < cols.length - 1) open += sum; });
root.querySelector('[data-pipe-total]').textContent = usd(open);
});
// 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 kanban boards where cards move between columns and the numbers follow them. Task board with a work-in-progress limit has To do, In progress and Done columns with a count in each header; In progress holds at most three cards, its count turns red when it is full, and a card dragged over it gets a red outline instead of a place. Sales pipeline with column totals has Lead, Qualified, Proposal and Won columns whose dollar totals, and the open total in the heading, are added up again after every move. Use the first for project and support boards and the second for deals, hiring or content pipelines. Each column is a list with an accessible name, so a move reads as “moved to Done, position 2 of 3”. Cards can be dragged from anywhere with a mouse and from their handle on touch screens, the whole column including its header accepts a drop, empty columns show a Drop cards here slot, and on narrow screens the columns stack. From the keyboard, Up and Down reorder a card and Left and Right carry it to the next column.
What’s included
Cards move between columns and within them
Column counts and dollar totals update live
Work-in-progress limit through data-dd-max, refused drops shown in red
Left and Right arrow keys move a card to the next column
Columns stack on small screens; the whole column is a drop 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 "Write the pricing page FAQ moved to Done, position 2 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
Set --kb-accent, --kb-col and --kb-danger on .kb, and data-dd-max on any column list to give it a limit. Bootstrap uses the .row grid with the same variables; Tailwind keeps the kb colours in @theme.