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:
5
.changeset/fix-expand-icon-kb642.md
Normal file
5
.changeset/fix-expand-icon-kb642.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@fusion/dashboard": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add missing expand icon to task cards for explicit modal open action
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user