5.7 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| ../../specs/tasks/subtask-reparenting-drag-drop.md | 2026-08-13 | done |
Fix Plan: Task drag must not start from the context menu
Root cause
The sidebar task tree (desktop sidebar and the mobile task switcher sheet)
wires dnd-kit's drag listeners onto a handle div that wraps the task row.
TaskRow renders TaskItemWithContextMenu, whose Radix ContextMenuContent
portal is a fiber descendant of that handle. React synthetic events do not
follow the DOM tree for portals; they bubble through the React fiber tree.
So a mousedown, touchstart, or pointerdown on any menu item (e.g. the
Color submenu trigger or one of its swatches) reaches the handle's sensor
listeners:
MouseSensor.activatorsignores only right-click (button === 2), so a left-press on a menu item activates the sensor; after the 8pxDRAG_ACTIVATION_DISTANCEthe row drags (dnd-kit source:MouseSensor.activatorsin@dnd-kit/core).TouchSensoractivates ontouchstartafter a 250ms hold — before a ~700ms long-press can open the context menu. A stationary long-press on the row therefore starts a drag that is still live when the menu opens on top of it; the drag only ends when the finger lifts, so continuing to drag inside the open menu moves/reorders the row.- A plain
clickon a menu item additionally fiber-bubbles to the row'sonClick→onSelectTask, activating the task as a side effect.
The kanban card context menu is not affected: there the
ContextMenuContent is a fiber sibling of the card shell that holds the
listeners, so no leak exists (verified structurally in
apps/web/components/kanban-card-context-menu.tsx). The kanban dropdown
menu was already fixed the same way (DropdownEntry guards
onClick/onPointerDown; see kanban-card-menu-items.tsx and its test).
Fix
Contain pointer-start and click events at the menu boundary in
TaskItemWithContextMenu (apps/web/components/task/task-switcher-context-menu.tsx):
- Bubble-phase containment on the
ContextMenuContentforonMouseDown,onPointerDown,onTouchStart, andonClickstops events on any menu item or submenu (fiber descendants) from reaching the drag handle or the row. Item handlers run first (they are deeper in the fiber tree), so menu actions still work; Radix outside-click dismissal and keyboard activation are unaffected. - Touch long-press cancellation: the touchstart target is captured on the
trigger wrapper (capture phase). When the menu opens, a
touchcancelTouchEventis dispatched at that element — dnd-kit'sTouchSensorlistens fortouchcancelon the touchstart target while a drag is active, so the in-flight long-press drag is cancelled (onDragCancel, no drop) instead of reordering the row. Inert on desktop (no touch sensor) and for quick taps (sensor already detached).
Tests
Unit regression (apps/web/components/task/task-switcher-context-menu.test.tsx)
Render TaskItemWithContextMenu inside a wrapper div with spy
onMouseDown/onPointerDown/onTouchStart/onClick handlers that stand in
for the drag handle and row click. Open the real menu and assert:
mousedown/pointerdownon theColorsubmenu trigger do not reach the wrapper (fails before the fix: the spies are called).touchstarton theColorsubmenu trigger and on a color swatch inside the open submenu do not reach the wrapper.clickon a menu item (e.g. Archive) still invokes its action (onArchiveTask) and does not reach the wrapper'sonClick.- Opening the menu dispatches a
touchcancelat the touchstart target (the signal that cancels an in-flight touch drag).
i18n is initialized globally (vitest.setup.ts), so the real English labels
render; wrap in StateProvider/ToastProvider per task-switcher.test.tsx.
happy-dom does not construct TouchEvent, so tests 2/4 stub it.
E2E (desktop, extend apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts)
Seed a parent with a subtask, right-click the subtask row, assert the context menu opens (Color item visible), then press the mouse down on the Color item, move ≥8px, assert no nest drop zone appears and the row order is unchanged, then release.
E2E (mobile, extend apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts)
Seed two root tasks, long-press a row via CDP touch (hold 1200ms), assert the context menu opens while no nest drop zone renders and the row is not dimmed, then drag inside the open menu and assert the row order and parent relationship are unchanged (native list scroll may shift both rows together).
Implementation Wave
- Guard context-menu pointer events — done, sequential.
- Cancel touch drag when the context menu opens — done, sequential.
Risks
- A capture-phase guard would break item selection (it would stop the event before it reaches the items); the guard must be bubble-phase on the content.
- Guarding only the top-level items would miss submenu content (Color); the content-level guard covers all fiber descendants.
- The kanban card context menu must stay untouched (no leak there); changing it would be speculative.
- The touch long-press cancel dispatches a synthetic
touchcancelat the touchstart target. It is inert when no touch sensor is armed (desktop right-click, quick taps) but any other listener oftouchcancelon that element would also see it; dnd-kit's sensor is the only known one. The 250–700ms window where the drag is live before the menu opens still shows a brief row dim/nest-zone flash on touch long-press; the drag is cancelled the moment the menu opens.