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:
-1181
File diff suppressed because one or more lines are too long
+1181
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user