kanban dashboard: multi-card drag visual feedback

- When dragging a selected card while multiple cards are selected, the
  browser ghost image now shows a 'N cards' badge instead of a single card.
- All selected cards in the original column are dimmed (opacity 0.45 +
  grayscale) during the drag so the user sees the whole set is in-flight.
- Uses React state for the dragged task id; event delegation on the board
  columns container to avoid deep prop threading.
This commit is contained in:
Yi Lok Enoch Lam 2026-05-10 13:21:13 +02:00 committed by Teknium
parent 98c499b235
commit a88f201cd4
2 changed files with 59 additions and 1 deletions

View file

@ -441,6 +441,9 @@
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [lastSelectedId, setLastSelectedId] = useState(null);
const [failedIds, setFailedIds] = useState(() => new Set());
const [draggingTaskId, setDraggingTaskId] = useState(null);
const handleDragStart = useCallback(function (taskId) { setDraggingTaskId(taskId); }, []);
const handleDragEnd = useCallback(function () { setDraggingTaskId(null); }, []);
// Per-task event counter incremented whenever the WS stream reports
// a new event for that task id. TaskDrawer useEffect-depends on its
// own task's counter so it reloads itself on live events instead of
@ -911,6 +914,9 @@
laneByProfile,
selectedIds,
failedIds,
draggingTaskId,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
toggleSelected,
toggleRange,
selectAllInColumn,
@ -1762,7 +1768,16 @@
// -------------------------------------------------------------------------
function BoardColumns(props) {
return h("div", { className: "hermes-kanban-columns" },
const handleDragStart = useCallback(function (e) {
const card = e.target.closest && e.target.closest(".hermes-kanban-card");
if (!card) return;
const taskId = card.getAttribute("data-task-id");
if (taskId && props.onDragStart) props.onDragStart(taskId);
}, [props.onDragStart]);
const handleDragEnd = useCallback(function () {
if (props.onDragEnd) props.onDragEnd();
}, [props.onDragEnd]);
return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd },
props.board.columns.map(function (col) {
return h(Column, {
key: col.name,
@ -1770,6 +1785,7 @@
laneByProfile: props.laneByProfile,
selectedIds: props.selectedIds,
failedIds: props.failedIds,
draggingTaskId: props.draggingTaskId,
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
selectAllInColumn: props.selectAllInColumn,
@ -1903,6 +1919,8 @@
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
draggingTaskId: props.draggingTaskId,
draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
@ -1915,6 +1933,8 @@
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
draggingTaskId: props.draggingTaskId,
draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
@ -1961,6 +1981,17 @@
const handleDragStart = function (e) {
e.dataTransfer.setData(MIME_TASK, t.id);
e.dataTransfer.effectAllowed = "move";
const selectedCards = document.querySelectorAll(".hermes-kanban-card--selected");
if (selectedCards.length > 1 && props.selected) {
const ghost = document.createElement("div");
ghost.className = "hermes-kanban-drag-ghost";
ghost.textContent = selectedCards.length + " cards";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
requestAnimationFrame(function () {
if (ghost.parentNode) document.body.removeChild(ghost);
});
}
};
const handleClick = function (e) {
if (e.shiftKey) {
@ -1995,10 +2026,12 @@
return h("div", {
ref: cardRef,
"data-task-id": t.id,
className: cn(
"hermes-kanban-card",
props.selected ? "hermes-kanban-card--selected" : "",
props.failed ? "hermes-kanban-card--failed" : "",
props.draggingSource ? "hermes-kanban-card--dragging-source" : "",
stalenessClass(t),
),
draggable: true,

View file

@ -538,6 +538,15 @@
background: color-mix(in srgb, var(--color-ring) 6%, var(--color-card));
}
/* Batch drag source styling cards that are part of the current multi-drag.
The browser ghost image floats; we dim the original DOM nodes so the user
sees the whole set is in-flight. */
.hermes-kanban-card--dragging-source :where(.hermes-kanban-card-content) {
opacity: 0.45;
filter: grayscale(0.6);
transition: opacity 120ms ease, filter 120ms ease;
}
.hermes-kanban-card-check {
width: 0.85rem;
height: 0.85rem;
@ -776,6 +785,22 @@
transition: none;
}
/* ---- Multi-drag ghost ----------------------------------------------- */
.hermes-kanban-drag-ghost {
position: fixed;
left: -9999px;
padding: 0.45rem 0.8rem;
background: var(--color-card);
border: 2px solid var(--color-ring);
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
pointer-events: none;
opacity: 0.95;
}
/* ---- Staleness tiers ------------------------------------------------ */