FN-109: enable task-card board panning
Allow users to pan the desktop and tablet board from noninteractive task-card surfaces without disrupting controls or mobile scrolling. - Start horizontal panning from eligible task-card bodies and text while preserving native controls and editing behavior. - Wire board pan state and cursor styling across live board roots while keeping mobile snap and touch scrolling unchanged. - Add regression coverage, documentation updates, and a patch changeset. Files changed: .changeset/fn-109-board-card-pan.md | 7 ++ docs/dashboard-guide.md | 5 +- docs/task-management.md | 4 +- packages/dashboard/app/components/Board.css | 7 +- packages/dashboard/app/components/Board.tsx | 8 +- .../app/components/__tests__/Board.test.tsx | 98 ++++++++++++++++------ .../app/hooks/__tests__/useBoardMousePan.test.tsx | 47 ++++++++++- packages/dashboard/app/hooks/useBoardMousePan.ts | 12 +-- 8 files changed, 146 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-109 Fusion-Task-Lineage: 456bd9d3-9767-4b82-9f99-f191abc44b18 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-109-board-card-pan.md
Normal file
7
.changeset/fn-109-board-card-pan.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Pan desktop and tablet Boards from noninteractive task card bodies.
|
||||
category: fix
|
||||
dev: Keeps task relocation in the contextual Move to menu.
|
||||
@@ -161,7 +161,10 @@ When task detail is open from a board card, task popup, mobile list row, right-d
|
||||
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||
|
||||
### Mobile Kanban column snapping
|
||||
### Board panning and mobile Kanban column snapping
|
||||
|
||||
<!-- FNXC:BoardNavigationDocs 2026-08-21-16:09: FN-109 documents that desktop/tablet card-body panning moves only the Board viewport; controls, editing, contextual movement, and phone touch ownership remain independent. -->
|
||||
On desktop and tablet, drag horizontally from an empty Board surface or a task card's noninteractive body or text to pan the Board viewport. The gesture never moves a task: card controls, links, fields, editing, context menus, and file drops keep their normal behavior, and **Move to** in the task context menu remains the only relocation path. Fusion does not auto-scroll at Board edges. Phone touch scrolling and column snapping are unchanged.
|
||||
|
||||
<!-- FNXC:BoardNavigationDocs 2026-08-18-19:10: Phone releases keep free finger scrolling, then use one smooth controlled normal-motion settle to the already-valid reachable column. Reduced-motion users still receive an immediate landing, while programmatic, refresh, resize, and restored-page scrolls remain unsnapped. -->
|
||||
On the mobile Kanban board, free-scroll while your finger is down. At release, Fusion resolves the existing directional or nearest-column target and moves continuously to that exact reachable column, so the board feels fluid instead of abruptly locking or visibly hard-jumping. Direction is locked at finger-up from the net swipe (not rubber-band ticks), and the result pins until the next touch. A direction-zero or off-center release uses the same controlled nearest-column settle, so the board never rests between columns. Reduced-motion preferences, negligible-distance corrections, or unavailable animation capability use an immediate safe landing instead.
|
||||
|
||||
@@ -362,12 +362,14 @@ Board ordering behavior:
|
||||
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
|
||||
- The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults).
|
||||
|
||||
<!-- FNXC:TaskCardMovement 2026-08-19-18:35: Task movement is contextual so Board and List retain one accessible, validated transition path without native task drag-and-drop. -->
|
||||
<!-- FNXC:TaskCardMovement 2026-08-21-16:09: FN-109 separates desktop/tablet Board viewport panning from contextual task movement so card-body drags cannot restore native relocation. -->
|
||||
|
||||
### Moving tasks on Board and List
|
||||
|
||||
Use a task card or List row's context menu (**right-click**, **Shift+F10** / Context Menu key, the visible overflow control, or touch long-press), then choose **Move to**. When more than one legal destination is available, **Move to** opens one submenu containing each destination; a single destination remains a direct action. This changes task movement only.
|
||||
|
||||
On desktop and tablet Boards, dragging a task card's noninteractive body or text pans the Board viewport without moving the task. Controls and editing remain native; **Move to** remains the only task-relocation path.
|
||||
|
||||
### Lifecycle commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -45,9 +45,10 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Feedback begins only after an eligible desktop/tablet safe-surface drag crosses horizontal intent.
|
||||
It never activates for excluded cards or controls, the unbound skeleton, or the disabled mobile path.
|
||||
FNXC:BoardNavigation 2026-08-21-16:03:
|
||||
FN-109 shows grab feedback only after a desktop/tablet pan crosses horizontal intent from a safe
|
||||
surface or noninteractive task-card body/text. Controls, editing cards, the unbound skeleton, and
|
||||
the disabled mobile path never activate it; this gesture pans the viewport and never relocates a task.
|
||||
*/
|
||||
.board.board-workflow-columns.is-mouse-panning {
|
||||
cursor: grabbing;
|
||||
|
||||
@@ -231,10 +231,10 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent
|
||||
const mobileFullTaskModalHidden = viewportMode === "mobile";
|
||||
useColumnScrollSnap(boardElement, { mobileOnly: true });
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Selected and All-workflows live roots share safe-surface primary-mouse panning outside mobile;
|
||||
empty-column text may pan, while cards and controls remain native. The skeleton stays unbound,
|
||||
and the separate mobile-only snap owner plus mobile styles remain unchanged.
|
||||
FNXC:BoardNavigation 2026-08-21-16:03:
|
||||
FN-109 shares one non-mobile primary-mouse pan owner across selected and All-workflows live roots.
|
||||
Empty surfaces plus noninteractive task-card bodies/text pan only the viewport; controls and editing
|
||||
stay native, task relocation stays menu-only, the skeleton stays unbound, and mobile snap ownership is unchanged.
|
||||
*/
|
||||
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement, viewportMode !== "mobile");
|
||||
const boardClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`;
|
||||
|
||||
@@ -76,6 +76,8 @@ vi.mock("../Column", () => ({
|
||||
canDropTask,
|
||||
onPlanningMode,
|
||||
taskWorkflowBadges,
|
||||
onOpenDetail,
|
||||
onMoveTask,
|
||||
}: {
|
||||
column: string;
|
||||
tasks: Task[];
|
||||
@@ -101,6 +103,8 @@ vi.mock("../Column", () => ({
|
||||
canDropTask?: unknown;
|
||||
onPlanningMode?: unknown;
|
||||
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string }>;
|
||||
onOpenDetail?: (task: Task) => void;
|
||||
onMoveTask?: (id: string, column: string) => Promise<Task>;
|
||||
}) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
@@ -117,8 +121,10 @@ vi.mock("../Column", () => ({
|
||||
) : null}
|
||||
{tasks.length === 0 ? <div className="empty-column">No tasks</div> : null}
|
||||
{tasks.map((task) => (
|
||||
<article key={task.id} data-id={task.id} data-testid={`board-task-card-${task.id}`}>
|
||||
{task.title ?? task.description ?? task.id}
|
||||
<article key={task.id} className="card" data-id={task.id} data-testid={`board-task-card-${task.id}`} onClick={() => onOpenDetail?.(task)}>
|
||||
<span data-testid={`board-task-card-title-${task.id}`}>{task.title ?? task.description ?? task.id}</span>
|
||||
<button type="button" data-testid={`board-task-card-control-${task.id}`} onClick={(event) => event.stopPropagation()}>card control</button>
|
||||
<span data-has-move-task={String(Boolean(onMoveTask))} />
|
||||
</article>
|
||||
))}
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
@@ -2037,38 +2043,82 @@ describe("Board", () => {
|
||||
expect(board.scrollLeft).toBe(scrollAfterPointerMove);
|
||||
});
|
||||
|
||||
it("keeps touch and task-card interactions native", async () => {
|
||||
it("pans from task-card bodies in selected and All-workflows Boards without moving or opening tasks", async () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({});
|
||||
enableFlag({ "FN-1": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" })], onQuickCreate });
|
||||
const onOpenDetail = vi.fn();
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
enableFlag({ "FN-1": "builtin:coding", "FN-2": "wf-custom" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })], onQuickCreate, onOpenDetail, onMoveTask });
|
||||
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerMove(board, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerUp(board, { pointerId: 1, pointerType: "touch" });
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveClass("board", "board-workflow-columns");
|
||||
|
||||
const card = screen.getByTestId("board-task-card-FN-1");
|
||||
const selectedBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(selectedBoard);
|
||||
const selectedCard = screen.getByTestId("board-task-card-FN-1");
|
||||
/*
|
||||
FNXC:TaskCardInteraction 2026-08-19-19:07:
|
||||
Native task dragging is retired; cards remain ordinary pointer targets so board panning excludes them without preserving the drag contract.
|
||||
FNXC:BoardNavigation 2026-08-21-16:03:
|
||||
FN-109 keeps native task movement retired. A noninteractive card body or title may only pan
|
||||
its owning Board; controls remain native and the Board capture guard consumes the following click.
|
||||
*/
|
||||
expect(card).not.toHaveAttribute("draggable");
|
||||
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" });
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(selectedCard).not.toHaveAttribute("draggable");
|
||||
fireEvent.pointerDown(selectedCard, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(selectedCard, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
expect(selectedBoard.scrollLeft).toBe(160);
|
||||
expect(selectedBoard).toHaveClass("is-mouse-panning");
|
||||
fireEvent.pointerUp(selectedCard, { pointerId: 1, pointerType: "mouse" });
|
||||
expect(selectedBoard).not.toHaveClass("is-mouse-panning");
|
||||
fireEvent.click(selectedCard);
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
|
||||
|
||||
fireEvent.click(selectedCard);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
const selectedControl = screen.getByTestId("board-task-card-control-FN-1");
|
||||
selectedBoard.scrollLeft = 100;
|
||||
fireEvent.pointerDown(selectedControl, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(selectedControl, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(selectedControl, { pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.click(selectedControl);
|
||||
expect(selectedBoard.scrollLeft).toBe(100);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const aggregateBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(aggregateBoard);
|
||||
const aggregateTitle = screen.getByTestId("board-task-card-title-FN-1");
|
||||
fireEvent.pointerDown(aggregateTitle, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(aggregateTitle, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
expect(aggregateBoard.scrollLeft).toBe(160);
|
||||
fireEvent.pointerUp(aggregateTitle, { pointerId: 3, pointerType: "mouse" });
|
||||
expect(aggregateBoard).not.toHaveClass("is-mouse-panning");
|
||||
fireEvent.click(aggregateTitle);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(aggregateTitle);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(2);
|
||||
|
||||
const quickCreate = screen.getByTestId("mock-quick-create-triage");
|
||||
fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(quickCreate, { pointerId: 3, pointerType: "mouse" });
|
||||
fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 4, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 4, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(quickCreate, { pointerId: 4, pointerType: "mouse" });
|
||||
fireEvent.click(quickCreate);
|
||||
expect(onQuickCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps touch input outside the desktop card-origin pan owner", () => {
|
||||
enableFlag({ "FN-1": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
const card = screen.getByTestId("board-task-card-FN-1");
|
||||
|
||||
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" });
|
||||
fireEvent.pointerUp(card, { pointerId: 1, pointerType: "touch" });
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveClass("board", "board-workflow-columns");
|
||||
});
|
||||
|
||||
it("disables desktop mouse panning at the mobile viewport without changing touch ownership", () => {
|
||||
const harness = installMobileBoardStabilizationHarness();
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,15 @@ function PanHarness({ enabled = true, onClick = vi.fn() }: { enabled?: boolean;
|
||||
<input aria-label="Editable" data-testid="input" />
|
||||
<div contentEditable data-testid="contenteditable">Editable content</div>
|
||||
<div draggable data-testid="draggable">Draggable</div>
|
||||
<article data-id="FN-1" data-testid="card">Card</article>
|
||||
<article data-id="FN-1" data-testid="card">
|
||||
<span data-testid="card-title">Card</span>
|
||||
<button type="button" data-testid="card-button">Card button</button>
|
||||
<a href="#card-link" data-testid="card-link">Card link</a>
|
||||
<input aria-label="Card editable" data-testid="card-input" />
|
||||
<div contentEditable data-testid="card-contenteditable">Card editable content</div>
|
||||
<div draggable data-testid="card-draggable">Card draggable</div>
|
||||
<div role="button" data-testid="card-semantic-control">Card semantic control</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -58,16 +66,49 @@ describe("useBoardMousePan", () => {
|
||||
expect(board.scrollLeft).toBe(130);
|
||||
});
|
||||
|
||||
it("leaves excluded interactive, editable, native-draggable, and task surfaces native", () => {
|
||||
it("pans task-card bodies and text while suppressing only the compatibility click", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board, getByTestId } = renderPanHarness(true, onClick);
|
||||
board.scrollLeft = 100;
|
||||
|
||||
pointerDown(getByTestId("card"));
|
||||
pointerMove(getByTestId("card"), 40);
|
||||
expect(board.scrollLeft).toBe(160);
|
||||
expect(board).toHaveAttribute("data-panning", "true");
|
||||
pointerUp(getByTestId("card"));
|
||||
fireEvent.click(getByTestId("card"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
board.scrollLeft = 100;
|
||||
pointerDown(getByTestId("card-title"), 100, 50, 2);
|
||||
pointerMove(getByTestId("card-title"), 140, 50, 2);
|
||||
expect(board.scrollLeft).toBe(60);
|
||||
pointerUp(getByTestId("card-title"), 2);
|
||||
fireEvent.click(getByTestId("card-title"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(getByTestId("card-title"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves interactive, editable, native-draggable, and editing-card surfaces native", () => {
|
||||
const { board, getByTestId } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
for (const [index, target] of ["button", "input", "contenteditable", "draggable", "card"].map(getByTestId).entries()) {
|
||||
for (const [index, target] of [
|
||||
"button", "input", "contenteditable", "draggable", "card-button", "card-link", "card-input",
|
||||
"card-contenteditable", "card-draggable", "card-semantic-control",
|
||||
].map(getByTestId).entries()) {
|
||||
pointerDown(target, 100, 50, index + 1);
|
||||
pointerMove(target, 40, 50, index + 1);
|
||||
pointerUp(target, index + 1);
|
||||
}
|
||||
|
||||
getByTestId("card").classList.add("card-editing");
|
||||
pointerDown(getByTestId("card"), 100, 50, 20);
|
||||
pointerMove(getByTestId("card"), 40, 50, 20);
|
||||
pointerUp(getByTestId("card"), 20);
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
});
|
||||
|
||||
@@ -29,17 +29,17 @@ export interface BoardMousePanBindings {
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Desktop and tablet Board navigation restores primary-mouse click-drag panning from safe,
|
||||
noninteractive descendants such as empty-column text. Interactive, editable, native-draggable,
|
||||
and task-card descendants keep their native behavior. Mobile owns touch scrolling and column snap,
|
||||
so this hook is disabled there; edge proximity and stationary pointers never continue scrolling.
|
||||
FNXC:BoardNavigation 2026-08-21-16:03:
|
||||
FN-109 permits desktop and tablet primary-mouse panning to begin on a canonical task card's
|
||||
noninteractive body or text now that task relocation is menu-only. Controls, editable content,
|
||||
native drag sources, and editing cards remain native; mobile retains touch scrolling and column
|
||||
snap ownership, while edge proximity and stationary pointers never continue scrolling.
|
||||
*/
|
||||
function isExcludedBoardPanTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
"button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [data-id], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']",
|
||||
".card-editing, button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user