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}
|
||||
|
||||
Reference in New Issue
Block a user