feat(kanban): drag-aware sidebar dropzone (issue 0091)

Add a 32px invisible strip on the left edge of the viewport that
auto-opens the sidebar when the user drags a card and dwells near the
edge for >=400ms. Removes the manual toggle step when moving cards to
sidebar-located columns.

- App.tsx: global mousemove listener while drag is active; 400ms hover
  timer; sets navOpen(true) when triggered; cancels on pointer leave or
  drag end. No auto-close on drag end (user keeps sidebar open).
- dropzone.css: subtle inset blue glow with pulse animation while
  pointer is inside the strip and a drag is active.
- KanbanColumn.tsx: add data-column-id and data-column-location to the
  Paper root for stable e2e selectors.
- e2e/sidebar-dropzone.spec.ts: Playwright test driving a slow drag
  to the left edge, asserting the strip arms, sidebar opens, and the
  card moves to a sidebar column via /api/board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:58:01 +02:00
parent 30def13c55
commit 257858a1f3
9 changed files with 1499 additions and 1184 deletions
+62
View File
@@ -172,6 +172,56 @@ export function App() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
// -------- Issue 0091 — drag-aware sidebar dropzone --------
// While a card or column is being dragged, watch the global pointer.
// If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
// We listen to mousemove globally because dnd-kit owns the pointer during
// drag, and the strip itself has pointer-events:none so dnd-kit keeps
// detecting drop targets underneath.
const DRAG_EDGE_WIDTH = 32;
const DRAG_EDGE_HOVER_MS = 400;
const isDragging = activeCard !== null || activeColumnId !== null;
const [edgeArmed, setEdgeArmed] = useState(false);
const navOpenRef = useRef(navOpen);
useEffect(() => {
navOpenRef.current = navOpen;
}, [navOpen]);
useEffect(() => {
if (!isDragging) {
setEdgeArmed(false);
return;
}
let timer: number | null = null;
let inside = false;
const clear = () => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const onMove = (ev: MouseEvent) => {
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
if (nowInside === inside) return;
inside = nowInside;
setEdgeArmed(nowInside);
if (nowInside) {
if (navOpenRef.current) return; // already open, nothing to do
clear();
timer = window.setTimeout(() => {
setNavOpen(true);
}, DRAG_EDGE_HOVER_MS);
} else {
clear();
}
};
document.addEventListener("mousemove", onMove);
return () => {
document.removeEventListener("mousemove", onMove);
clear();
setEdgeArmed(false);
};
}, [isDragging]);
const reload = useCallback(async () => {
try {
const b = await api.getBoard();
@@ -941,6 +991,18 @@ export function App() {
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
{/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
<div
className={
"kanban-drag-edge" +
(isDragging ? " is-active" : "") +
(edgeArmed ? " is-armed" : "")
}
data-test="kanban-drag-edge"
data-active={isDragging ? "1" : "0"}
data-armed={edgeArmed ? "1" : "0"}
aria-hidden="true"
/>
<AppShell
header={headerConfig}
navbar={navbarConfig}
+2
View File
@@ -261,6 +261,8 @@ function KanbanColumnImpl({
withBorder
radius="md"
p="sm"
data-column-id={column.id}
data-column-location={column.location}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
+1
View File
@@ -1,6 +1,7 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./styles/roulette.css";
import "./styles/dropzone.css";
import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
+52
View File
@@ -0,0 +1,52 @@
/* Drag-aware dropzone strip on the left edge.
* Issue 0091 — auto-open sidebar when dragging a card near the left edge.
*
* The strip is only visible while a drag is active. When the pointer is
* inside the strip, we add the `is-armed` class to show a subtle inset
* glow that pulses, so the user knows the zone is going to fire.
*/
.kanban-drag-edge {
position: fixed;
left: 0;
top: 50px; /* AppShell.Header height */
bottom: 0;
width: 32px;
z-index: 200;
pointer-events: none; /* let dnd-kit keep capturing the pointer */
opacity: 0;
transition: opacity 120ms ease-out, box-shadow 160ms ease-out, background 160ms ease-out;
background: transparent;
}
.kanban-drag-edge.is-active {
opacity: 1;
/* Very subtle hint that the strip exists during any drag. */
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.04);
}
.kanban-drag-edge.is-armed {
background: linear-gradient(
90deg,
rgba(34, 139, 230, 0.18) 0%,
rgba(34, 139, 230, 0.06) 60%,
transparent 100%
);
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4);
animation: kanban-drag-edge-pulse 1100ms ease-in-out infinite;
}
@keyframes kanban-drag-edge-pulse {
0% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
50% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-5),
inset 16px 0 22px -10px rgba(34, 139, 230, 0.35);
}
100% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
}