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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-CsQHDHWL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-S1AyDjRq.css">
<script type="module" crossorigin src="/assets/index-D3fjM31T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
<div id="root"></div>
+198
View File
@@ -0,0 +1,198 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_drag_drop } from "../../../../frontend/functions/browser/pw_drag_drop";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
interface BoardColumn {
id: string;
name: string;
location: "board" | "sidebar";
position: number;
}
interface BoardCard {
id: string;
column_id: string;
title: string;
}
interface BoardResponse {
columns: BoardColumn[];
cards: BoardCard[];
}
test.describe("Issue 0091 — sidebar drag dropzone", () => {
test("drag near left edge opens sidebar and drop moves card to sidebar column", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pre-req: ensure there is at least one sidebar column and a card on the board.
const initialBoard: BoardResponse = await page.request
.get("/api/board")
.then((r) => r.json());
let sidebarCol = initialBoard.columns.find((c) => c.location === "sidebar");
if (!sidebarCol) {
const created = await page.request
.post("/api/columns", {
data: { name: "E2E Sidebar", location: "sidebar" },
})
.then((r) => r.json());
sidebarCol = created as BoardColumn;
}
const boardCol = initialBoard.columns.find((c) => c.location !== "sidebar");
if (!boardCol) {
test.skip(true, "no board column to drag a card from");
return;
}
// Ensure at least one card exists in a board column we can drag.
let card = initialBoard.cards.find((c) => c.column_id === boardCol.id);
if (!card) {
const created = await page.request
.post("/api/cards", {
data: {
column_id: boardCol.id,
title: `e2e dropzone card ${Date.now()}`,
requester: "e2e",
},
})
.then((r) => r.json());
card = created as BoardCard;
// Reload UI so the new card appears.
await page.reload();
await page.waitForLoadState("networkidle");
}
// Sanity: side bar should start closed. The toggle button has aria-label="Toggle sidebar".
const toggleBtn = page.locator('button[aria-label="Toggle sidebar"]');
await expect(toggleBtn).toBeVisible();
// The Mantine Navbar has a known data attribute (data-mantine-component=AppShellNavbar)
// but the simplest check is: when collapsed, the desktop navbar is hidden via display:none.
// We use the strip element's visibility too.
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
// While not dragging, strip is_active=0.
await expect(strip).toHaveAttribute("data-active", "0");
const cardLocator = page.locator(`[data-card-id="${card!.id}"]`);
await expect(cardLocator).toBeVisible();
// Build a "left edge" target by creating a 1x100 box near x=10 to drop on.
// pw_drag_drop expects a Locator for the target; we use the strip itself
// even though pointer-events:none — page.mouse.move works against the
// viewport so its bounding box only drives where the pointer goes.
// We override hoverMs=700 so the 400ms timer fires well within the hover.
// Get the card bounding box.
const cardBox = await cardLocator.boundingBox();
if (!cardBox) throw new Error("card has no bounding box");
// Manually drive the pointer: press down on card, drag to x=10, dwell 700ms,
// assert sidebar opened (via predicate on toggle button aria-pressed OR the
// strip's data-active attribute observed), then drop on sidebar column.
const sx = cardBox.x + cardBox.width / 2;
const sy = cardBox.y + cardBox.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Cross dnd-kit's 5px activation threshold (we configured PointerSensor distance:5).
await page.mouse.move(sx + 15, sy, { steps: 4 });
// Glide towards x=10 (inside the 32px strip).
const edgeX = 10;
const edgeY = sy; // keep vertical, change horizontal.
const steps = 25;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const xi = (sx + 15) + (edgeX - (sx + 15)) * t;
const yi = sy + (edgeY - sy) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(16);
}
// Now dwell inside the strip — the 400ms timer should fire.
// While dwelling, every ~50ms we nudge the mouse 1px to keep dnd-kit pointer events alive
// but stay inside the strip.
const dwellMs = 700;
const nudgeStart = Date.now();
while (Date.now() - nudgeStart < dwellMs) {
await page.mouse.move(edgeX + ((Date.now() / 50) % 2), edgeY);
await page.waitForTimeout(50);
}
// Assert: the strip is now armed AND the sidebar opened.
await expect(strip).toHaveAttribute("data-armed", "1");
// Wait for sidebar column header text to appear (sidebar opened).
await pw_wait_predicate(
page,
(sidebarName: string) => {
const els = Array.from(document.querySelectorAll('[data-column-location="sidebar"]'));
// Element must be visible (offsetParent != null is a good proxy for display!=none).
return els.some((el) => (el as HTMLElement).offsetParent !== null);
},
{
arg: sidebarCol!.name,
timeoutMs: 3000,
pollMs: 100,
message: "sidebar column did not become visible after dwell",
}
);
// Now move pointer to the sidebar column and release.
const sidebarColLoc = page.locator(`[data-column-id="${sidebarCol!.id}"]`).first();
await expect(sidebarColLoc).toBeVisible();
const sbBox = await sidebarColLoc.boundingBox();
if (!sbBox) throw new Error("sidebar column has no bounding box");
const tx = sbBox.x + sbBox.width / 2;
const ty = sbBox.y + sbBox.height / 2;
const dropSteps = 15;
let lastX = edgeX;
let lastY = edgeY;
for (let i = 1; i <= dropSteps; i++) {
const t = i / dropSteps;
const xi = lastX + (tx - lastX) * t;
const yi = lastY + (ty - lastY) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(20);
}
await page.waitForTimeout(150);
await page.mouse.up();
// Validate via API the card moved to the sidebar column.
await pw_wait_predicate(
page,
async (args: { id: string; col: string }) => {
const res = await fetch("/api/board", { credentials: "same-origin" });
const b = await res.json();
const c = (b.cards as BoardCard[]).find((x) => x.id === args.id);
return c?.column_id === args.col;
},
{
arg: { id: card!.id, col: sidebarCol!.id },
timeoutMs: 5000,
pollMs: 200,
message: "card did not land in sidebar column after drop",
}
);
});
test("strip stays inactive when there is no drag", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
await expect(strip).toHaveAttribute("data-active", "0");
await expect(strip).toHaveAttribute("data-armed", "0");
// Move the pointer over the left edge — without a drag, strip must stay disarmed.
await page.mouse.move(10, 200);
await page.waitForTimeout(600);
await expect(strip).toHaveAttribute("data-armed", "0");
});
});
+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);
}
}