feat(kanban): add grab-to-pan board scrolling

Adds grab-to-pan horizontal scrolling to the Kanban dashboard board
columns with grab/grabbing cursor feedback. Card drag-and-drop, add
buttons, checkboxes, links, and inputs are excluded from panning, the
native horizontal scrollbar remains usable as a fallback, and panning
state is cleared on mouse release or window blur.

Salvaged from PR #59830 by @vampyren (original commit authored under an
unlinked local git identity; rewritten to their GitHub noreply address
to preserve attribution).
This commit is contained in:
vampyren 2026-07-07 05:26:04 -07:00 committed by Teknium
parent afb5808d8c
commit cc0aa18fe7
2 changed files with 107 additions and 1 deletions

View file

@ -2273,6 +2273,93 @@
// -------------------------------------------------------------------------
function BoardColumns(props) {
const columnsRef = useRef(null);
const panRef = useRef({ isPanning: false, startX: 0, scrollLeft: 0 });
const [isPanning, setIsPanning] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const checkScrollable = useCallback(function () {
const el = columnsRef.current;
setIsScrollable(!!el && el.scrollWidth > el.clientWidth + 1);
}, []);
useEffect(function () {
checkScrollable();
const el = columnsRef.current;
if (!el) return undefined;
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(checkScrollable);
observer.observe(el);
return function () { observer.disconnect(); };
}
window.addEventListener("resize", checkScrollable);
return function () { window.removeEventListener("resize", checkScrollable); };
}, [checkScrollable, props.board]);
const isPanBlockedTarget = useCallback(function (target) {
if (!target) return true;
if (target.closest && target.closest(".hermes-kanban-card")) return true;
if (target.closest && target.closest(".hermes-kanban-column-add")) return true;
if (target.closest && target.closest(".hermes-kanban-col-check")) return true;
if (target.closest && target.closest("button,input,textarea,select,a,[role='button']")) return true;
return false;
}, []);
const stopPan = useCallback(function () {
const el = columnsRef.current;
if (!panRef.current.isPanning) return;
panRef.current.isPanning = false;
setIsPanning(false);
if (el) {
// Keep cursor feedback instant even before React flushes the state update.
el.classList.remove("hermes-kanban-columns--panning");
el.style.userSelect = "";
}
if (panRef.current.cleanup) panRef.current.cleanup();
panRef.current.cleanup = null;
}, []);
useEffect(function () {
return function () { stopPan(); };
}, [stopPan]);
const handleMouseDown = useCallback(function (e) {
if (e.button !== 0) return;
if (isPanBlockedTarget(e.target)) return;
const el = columnsRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Preserve the native horizontal scrollbar as a fallback; grab-pan starts above it.
if (e.clientY >= rect.bottom - 20) return;
if (el.scrollWidth <= el.clientWidth) return;
panRef.current.isPanning = true;
panRef.current.startX = e.clientX;
panRef.current.scrollLeft = el.scrollLeft;
setIsPanning(true);
el.classList.add("hermes-kanban-columns--panning");
el.style.userSelect = "none";
function onMouseMove(ev) {
if (!panRef.current.isPanning) return;
const dx = ev.clientX - panRef.current.startX;
el.scrollLeft = panRef.current.scrollLeft - dx;
ev.preventDefault();
}
function onMouseUp() { stopPan(); }
if (panRef.current.cleanup) panRef.current.cleanup();
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp, { once: true });
window.addEventListener("blur", onMouseUp, { once: true });
panRef.current.cleanup = function () {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("blur", onMouseUp);
};
e.preventDefault();
}, [isPanBlockedTarget, stopPan]);
const handleDragStart = useCallback(function (e) {
const card = e.target.closest && e.target.closest(".hermes-kanban-card");
if (!card) return;
@ -2282,7 +2369,17 @@
const handleDragEnd = useCallback(function () {
if (props.onDragEnd) props.onDragEnd();
}, [props.onDragEnd]);
return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd },
return h("div", {
ref: columnsRef,
className: cn(
"hermes-kanban-columns",
isScrollable ? "hermes-kanban-columns--scrollable" : "",
isPanning ? "hermes-kanban-columns--panning" : "",
),
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
onMouseDown: handleMouseDown,
},
props.board.columns.map(function (col) {
return h(Column, {
key: col.name,

View file

@ -69,6 +69,15 @@
overflow-x: auto;
}
.hermes-kanban-columns--scrollable {
cursor: grab;
}
.hermes-kanban-columns--panning,
.hermes-kanban-columns--panning * {
cursor: grabbing !important;
}
.hermes-kanban-column {
flex: 0 0 280px;
display: flex;