feat(FN-817): add expand button and swipe-friendly touch behavior for board cards
- Add expand button to TaskCard for opening task detail modal on mobile - Implement CSS scroll-snap on board columns for swipe-friendly navigation - Add touch-action and overscroll-behavior styles for smoother mobile scrolling - Add comprehensive tests for mobile scroll snap behavior and expand button - Document expand button and swipe-friendly board behavior in README
This commit is contained in:
@@ -39,7 +39,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- `POST /api/planning/create-task` - Create task from summary (`{ sessionId }`)
|
||||
|
||||
### Task Management
|
||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done). Each card includes an explicit expand button (↗ icon in the card header) for opening task details — visible on hover for desktop, always visible on mobile. On touch devices, horizontal swipes that start on a card still scroll the board between columns; only quick taps open the task detail modal.
|
||||
- **Inline Editing**: Quick-edit a task's description directly on the board for Triage and Todo columns. The editor opens as a taller multi-line editing area (4 visible lines) for comfortable editing of longer descriptions, and auto-grows to fit existing content. Double-click a card or use the pencil icon — visible on hover for desktop, always visible on mobile for touch accessibility. Inline editing changes only the description; the title is preserved. To edit both title and description, use the task detail modal.
|
||||
- **Task Detail Editing**: Edit task title and description directly in the task detail modal. Click the pencil icon in the modal header (available for Triage and Todo tasks) to enter edit mode. Save and Cancel actions appear in the modal footer alongside a keyboard shortcut hint, keeping editing controls consistent with other modal action patterns.
|
||||
- **List View**: Alternative tabular view for tasks with sorting and filtering. The "Hide Done" toggle hides both Done and Archived tasks for an active-work-only view.
|
||||
|
||||
@@ -59,4 +59,20 @@ describe("scroll-snap CSS", () => {
|
||||
expect(afterMedia).toContain("scroll-snap-align: center");
|
||||
});
|
||||
});
|
||||
|
||||
describe("card touch-action", () => {
|
||||
it("allows horizontal panning so board swipes work when starting on a card", () => {
|
||||
const cardBlockMatch = css.match(/\.card\s*\{[^}]*\}/s);
|
||||
expect(cardBlockMatch).not.toBeNull();
|
||||
expect(cardBlockMatch![0]).toContain("touch-action: pan-x pan-y");
|
||||
});
|
||||
|
||||
it("does not use touch-action: pan-y alone (which blocks horizontal swipes)", () => {
|
||||
// The card should NOT have just pan-y; it needs pan-x too
|
||||
const cardBlockMatch = css.match(/\.card\s*\{[^}]*\}/s);
|
||||
expect(cardBlockMatch).not.toBeNull();
|
||||
const cardBlock = cardBlockMatch![0];
|
||||
expect(cardBlock).not.toMatch(/touch-action:\s*pan-y\s*;/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Maximize2 } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
@@ -462,6 +462,11 @@ function TaskCardComponent({
|
||||
enterEditMode(e);
|
||||
}, [enterEditMode]);
|
||||
|
||||
const handleExpandClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
void handleClick();
|
||||
}, [handleClick]);
|
||||
|
||||
// Auto-resize textarea (similar to InlineCreateCard)
|
||||
const handleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditDescription(e.target.value);
|
||||
@@ -575,6 +580,14 @@ function TaskCardComponent({
|
||||
/>
|
||||
)}
|
||||
<div className="card-header-actions">
|
||||
<button
|
||||
className="card-expand-btn"
|
||||
onClick={handleExpandClick}
|
||||
title="Open task details"
|
||||
aria-label="Open task details"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="card-edit-btn"
|
||||
|
||||
@@ -2515,11 +2515,11 @@ describe("TaskCard detail opening", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a separate expand button", () => {
|
||||
it("renders an expand button for opening task details", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /Open task details/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens modal only once per card click", async () => {
|
||||
@@ -2649,7 +2649,7 @@ describe("TaskCard detail opening", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render an expand button in any column", () => {
|
||||
it("renders an expand button in every column", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
for (const column of columns) {
|
||||
@@ -2663,11 +2663,86 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /Open task details/i })).toBeDefined();
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("expand button opens task detail modal", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandBtn = screen.getByRole("button", { name: /Open task details/i });
|
||||
fireEvent.click(expandBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
});
|
||||
|
||||
it("expand button click does not trigger card body click", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandBtn = screen.getByRole("button", { name: /Open task details/i });
|
||||
|
||||
// Use a real click event to test stopPropagation
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
const stopSpy = vi.spyOn(clickEvent, "stopPropagation");
|
||||
|
||||
fireEvent(expandBtn, clickEvent);
|
||||
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
// The handler should still call fetchTaskDetail via handleClick
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("expand button has correct styling class", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandBtn = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandBtn.classList.contains("card-expand-btn")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -809,7 +809,7 @@ body {
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-normal);
|
||||
user-select: none;
|
||||
touch-action: pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.card:hover {
|
||||
background: var(--card-hover);
|
||||
@@ -5090,6 +5090,22 @@ body {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Card expand button: always visible on mobile (no hover) */
|
||||
.card-expand-btn {
|
||||
opacity: 1;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-right: -8px;
|
||||
margin-top: -8px;
|
||||
margin-bottom: -8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-expand-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet Responsive Tier (769px–1024px) === */
|
||||
|
||||
Reference in New Issue
Block a user