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:
@@ -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