feat(KB-642): add expand button to TaskCard component

- Add expand button to TaskCard with Maximize2 icon
- Add CSS styles for expand button overlay positioning
- Fix click behavior to prevent card expansion when clicking action buttons
- Update TaskCard tests for new expand button behavior
- Add changeset for dashboard package
This commit is contained in:
gsxdsm
2026-03-31 19:20:53 -07:00
parent 9490792afd
commit d3b08ba3da
5 changed files with 108 additions and 240 deletions

View File

@@ -0,0 +1,5 @@
---
"@fusion/dashboard": patch
---
Add missing expand icon to task cards for explicit modal open action

View File

@@ -8,6 +8,12 @@ vi.mock("lucide-react", () => ({
Link: () => null, Link: () => null,
Clock: () => null, Clock: () => null,
Pencil: () => null, Pencil: () => null,
Maximize2: () => null,
Layers: () => null,
ChevronDown: () => null,
Folder: () => null,
GitPullRequest: () => null,
CircleDot: () => null,
})); }));
// Mock the api module // Mock the api module

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; 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 type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment } from "../api"; import { fetchTaskDetail, uploadAttachment } from "../api";
import { GitHubBadge } from "./GitHubBadge"; import { GitHubBadge } from "./GitHubBadge";
@@ -138,20 +138,10 @@ function TaskCardComponent({
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null); const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(); const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
// Touch gesture detection refs
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
const hasTouchMovedRef = useRef(false);
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false;
return !!target.closest("button, a, input, textarea, select, label, [role='button']");
}, []);
// Reset edit state when task changes // Reset edit state when task changes
useEffect(() => { useEffect(() => {
setEditTitle(task.title || ""); setEditTitle(task.title || "");
@@ -242,58 +232,10 @@ function TaskCardComponent({
} }
}, [task.id, onOpenDetail, addToast, isEditing]); }, [task.id, onOpenDetail, addToast, isEditing]);
const handleCardClick = useCallback((e: React.MouseEvent) => { const handleExpandClick = useCallback((e: React.MouseEvent) => {
if (touchOpenHandledRef.current) { e.stopPropagation();
touchOpenHandledRef.current = false;
return;
}
if (isInteractiveTarget(e.target)) return;
void handleClick(); void handleClick();
}, [handleClick, isInteractiveTarget]); }, [handleClick]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
hasTouchMovedRef.current = false;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!touchStartPosRef.current) return;
const touch = e.touches[0];
if (!touch) return;
const dx = Math.abs(touch.clientX - touchStartPosRef.current.x);
const dy = Math.abs(touch.clientY - touchStartPosRef.current.y);
// If moved beyond threshold, mark as moved (scrolling/dragging)
if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) {
hasTouchMovedRef.current = true;
}
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (isInteractiveTarget(e.target)) return;
// Check if this was a valid tap (not a scroll)
if (!touchStartPosRef.current) return;
const touchDuration = Date.now() - touchStartPosRef.current.time;
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
const isStationary = !hasTouchMovedRef.current;
// Only open modal for quick taps that didn't move significantly
if (isQuickTap && isStationary) {
touchOpenHandledRef.current = true;
void handleClick();
}
// Reset touch tracking
touchStartPosRef.current = null;
hasTouchMovedRef.current = false;
}, [handleClick, isInteractiveTarget]);
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => { const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
e.stopPropagation(); // Prevent card click e.stopPropagation(); // Prevent card click
@@ -560,10 +502,6 @@ function TaskCardComponent({
onDragOver={handleFileDragOver} onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave} onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop} onDrop={handleFileDrop}
onClick={handleCardClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
> >
<div className="card-header"> <div className="card-header">
@@ -594,6 +532,14 @@ function TaskCardComponent({
/> />
)} )}
<div className="card-header-actions"> <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 && ( {canEdit && (
<button <button
className="card-edit-btn" className="card-edit-btn"
@@ -732,8 +678,5 @@ function TaskCardComponent({
); );
} }
const TOUCH_MOVE_THRESHOLD = 10; // pixels
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual); export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
TaskCard.displayName = "TaskCard"; TaskCard.displayName = "TaskCard";

View File

@@ -21,6 +21,18 @@ vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => mockUseBadgeWebSocket(), useBadgeWebSocket: () => mockUseBadgeWebSocket(),
})); }));
vi.mock("lucide-react", () => ({
Link: ({ size }: { size?: number }) => <span data-testid="link-icon">🔗</span>,
Clock: ({ size }: { size?: number }) => <span data-testid="clock-icon">🕐</span>,
Layers: ({ size }: { size?: number }) => <span data-testid="layers-icon">📚</span>,
Pencil: ({ size }: { size?: number }) => <span data-testid="pencil-icon">✏️</span>,
ChevronDown: ({ size, className }: { size?: number; className?: string }) => <span data-testid="chevron-icon" className={className}>▼</span>,
Folder: ({ size }: { size?: number }) => <span data-testid="folder-icon">📁</span>,
Maximize2: ({ size }: { size?: number }) => <span data-testid="maximize-icon">⛶</span>,
GitPullRequest: ({ size }: { size?: number }) => <span data-testid="git-pr-icon">🔀</span>,
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon">⭕</span>,
}));
beforeEach(() => { beforeEach(() => {
mockUseBadgeWebSocket.mockReset(); mockUseBadgeWebSocket.mockReset();
mockUseBadgeWebSocket.mockReturnValue({ mockUseBadgeWebSocket.mockReturnValue({
@@ -2283,18 +2295,18 @@ describe("TaskCard GitHub badges", () => {
}); });
/** /**
* Tests for touch gesture handling in TaskCard. * Tests for expand button and modal open behavior in TaskCard.
* Ensures that scrolling/dragging does not accidentally open the modal, * Ensures that clicking the expand button opens the modal,
* while intentional taps still work correctly. * while clicking the card body does not.
*/ */
describe("TaskCard touch gesture handling", () => { describe("TaskCard expand button", () => {
const noopToast = vi.fn(); const noopToast = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("opens modal on quick tap without movement", async () => { it("opens modal when clicking the expand button", async () => {
const { fetchTaskDetail } = await import("../../api"); const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail); const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = { const mockDetail: TaskDetail = {
@@ -2315,21 +2327,11 @@ describe("TaskCard touch gesture handling", () => {
/> />
); );
const card = document.querySelector('[data-id="KB-099"]'); const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(card).toBeDefined(); expect(expandButton).toBeDefined();
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
// Simulate a quick tap: touchStart, then touchEnd without touchMove fireEvent.click(expandButton);
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
// Small delay but still within tap duration
await new Promise((resolve) => setTimeout(resolve, 50));
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: card,
});
await waitFor(() => { await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("KB-099"); expect(mockFetch).toHaveBeenCalledWith("KB-099");
@@ -2337,10 +2339,10 @@ describe("TaskCard touch gesture handling", () => {
}); });
}); });
it("does NOT open modal when touch moves beyond threshold (scrolling)", async () => { it("does NOT open modal when clicking the card body", async () => {
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
const task = makeTask(); const task = makeTask({ title: "Test Task Title" });
render( render(
<TaskCard <TaskCard
@@ -2353,19 +2355,9 @@ describe("TaskCard touch gesture handling", () => {
const card = document.querySelector('[data-id="KB-099"]'); const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined(); expect(card).toBeDefined();
// Simulate scrolling: touchStart, touchMove with significant movement, then touchEnd // Click on the card title (part of card body)
fireEvent.touchStart(card!, { const cardTitle = screen.getByText("Test Task Title");
touches: [{ clientX: 100, clientY: 100 }], fireEvent.click(cardTitle);
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 120, clientY: 120 }], // 20px movement (> 10px threshold)
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 120, clientY: 120 }],
target: card,
});
// Wait for any async operations // Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -2374,143 +2366,46 @@ describe("TaskCard touch gesture handling", () => {
expect(onOpenDetail).not.toHaveBeenCalled(); expect(onOpenDetail).not.toHaveBeenCalled();
}); });
it("does NOT open modal during vertical scrolling", async () => { it("expand button has correct accessibility attributes", () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask()}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 100, clientY: 115 }],
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 115 }],
target: card,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal during horizontal scrolling", async () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask()}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 115, clientY: 100 }],
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 115, clientY: 100 }],
target: card,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal on long press (slow touch)", async () => {
const onOpenDetail = vi.fn();
const task = makeTask(); const task = makeTask();
render( render(
<TaskCard <TaskCard
task={task} task={task}
onOpenDetail={onOpenDetail} onOpenDetail={vi.fn()}
addToast={noopToast} addToast={noopToast}
/> />
); );
const card = document.querySelector('[data-id="KB-099"]'); const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(card).toBeDefined(); expect(expandButton).toBeDefined();
expect(expandButton.getAttribute("aria-label")).toBe("Open task details");
// Simulate long press: touchStart, wait > 300ms, then touchEnd expect(expandButton.getAttribute("title")).toBe("Open task details");
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
// Wait longer than tap threshold (300ms)
await new Promise((resolve) => setTimeout(resolve, 350));
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: card,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened
expect(onOpenDetail).not.toHaveBeenCalled();
}); });
it("does NOT open modal when touch starts on interactive element (button)", async () => { it("expand button is present in all columns", () => {
const onOpenDetail = vi.fn(); const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
const onArchiveTask = vi.fn().mockResolvedValue(makeTask());
const task = makeTask({ column: "done" }); for (const column of columns) {
const task = makeTask({ column });
render( const { unmount } = render(
<TaskCard <TaskCard
task={task} task={task}
onOpenDetail={onOpenDetail} onOpenDetail={vi.fn()}
addToast={noopToast} addToast={noopToast}
onArchiveTask={onArchiveTask} />
/> );
);
// Find the archive button (an interactive element) const expandButton = screen.getByRole("button", { name: /Open task details/i });
const archiveButton = screen.getByRole("button", { name: /Archive task/i }); expect(expandButton).toBeDefined();
expect(archiveButton).toBeDefined(); expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
// Simulate touch on the button unmount();
fireEvent.touchStart(archiveButton, { }
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchEnd(archiveButton, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: archiveButton,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened (button click should be handled separately)
expect(onOpenDetail).not.toHaveBeenCalled();
}); });
it("opens modal for tap with minimal movement within threshold", async () => { it("expand button stops propagation to prevent double-triggering", async () => {
const { fetchTaskDetail } = await import("../../api"); const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail); const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = { const mockDetail: TaskDetail = {
@@ -2531,26 +2426,13 @@ describe("TaskCard touch gesture handling", () => {
/> />
); );
const card = document.querySelector('[data-id="KB-099"]'); const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(card).toBeDefined();
// Simulate tap with small movement (5px, under 10px threshold) // Click the expand button - should only trigger once
fireEvent.touchStart(card!, { fireEvent.click(expandButton);
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 105, clientY: 105 }], // 5px movement (< 10px threshold)
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 105, clientY: 105 }],
target: card,
});
await waitFor(() => { await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("KB-099"); expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
}); });
}); });
}); });

View File

@@ -1988,6 +1988,38 @@ body {
outline-offset: 1px; outline-offset: 1px;
} }
/* Expand button - visible on hover */
.card-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.card:hover .card-expand-btn {
opacity: 1;
}
.card-expand-btn:hover {
background: var(--border);
color: var(--text);
}
.card-expand-btn:focus {
opacity: 1;
outline: 1px solid var(--todo);
outline-offset: 1px;
}
/* Archive/Unarchive buttons */ /* Archive/Unarchive buttons */
.card-archive-btn, .card-archive-btn,
.card-unarchive-btn { .card-unarchive-btn {