feat(FN-1880): add mobile roadmap controls - list, create, navigate
- Add mobile-friendly roadmap view with list, create, and navigation controls - Implement RoadmapCard and inline creation form for quick roadmap entry - Add mobile-optimized navigation between roadmap views - Include mobile touch targets and responsive layout using design tokens - Add comprehensive tests for RoadmapsView mobile components
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft } from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
@@ -266,6 +267,190 @@ function RoadmapItem({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Roadmap List ──────────────────────────────────────────────
|
||||
|
||||
function MobileRoadmapList({
|
||||
roadmaps,
|
||||
selectedRoadmapId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onExport,
|
||||
showCreateForm,
|
||||
onCancelCreate,
|
||||
onSaveCreate,
|
||||
}: {
|
||||
roadmaps: Roadmap[];
|
||||
selectedRoadmapId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (roadmap: Roadmap) => void;
|
||||
onDelete: (roadmapId: string) => void;
|
||||
onExport: (roadmap: Roadmap) => void;
|
||||
showCreateForm: boolean;
|
||||
onCancelCreate: () => void;
|
||||
onSaveCreate: (input: RoadmapCreateInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="roadmaps-view__mobile-list" data-testid="roadmaps-view__mobile-list">
|
||||
<div className="roadmaps-view__mobile-list-header">
|
||||
<h2 className="roadmaps-view__mobile-list-title">Roadmaps</h2>
|
||||
{!showCreateForm && (
|
||||
<button
|
||||
className="roadmaps-view__mobile-add-btn"
|
||||
onClick={onCreate}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="mobile-create-roadmap-btn"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="roadmaps-view__mobile-create-form">
|
||||
<CreateRoadmapForm onSave={onSaveCreate} onCancel={onCancelCreate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roadmaps.length === 0 && !showCreateForm ? (
|
||||
<div className="roadmaps-view__mobile-empty">
|
||||
<p>No roadmaps yet.</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={onCreate}>
|
||||
<Plus size={14} />
|
||||
<span>Create Roadmap</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="roadmaps-view__mobile-list-items">
|
||||
{roadmaps.map((roadmap) => (
|
||||
<div
|
||||
key={roadmap.id}
|
||||
className={`roadmaps-view__mobile-item${roadmap.id === selectedRoadmapId ? " roadmaps-view__mobile-item--active" : ""}`}
|
||||
onClick={() => onSelect(roadmap.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSelect(roadmap.id);
|
||||
}
|
||||
}}
|
||||
data-testid={`mobile-roadmap-item-${roadmap.id}`}
|
||||
>
|
||||
<div className="roadmaps-view__mobile-item-content">
|
||||
<span className="roadmaps-view__mobile-item-title">{roadmap.title}</span>
|
||||
{roadmap.description && (
|
||||
<span className="roadmaps-view__mobile-item-desc">{roadmap.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmaps-view__mobile-item-actions">
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExport(roadmap);
|
||||
}}
|
||||
title="Export roadmap"
|
||||
aria-label="Export roadmap"
|
||||
data-testid={`mobile-roadmap-export-${roadmap.id}`}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(roadmap);
|
||||
}}
|
||||
title="Edit roadmap"
|
||||
aria-label="Edit roadmap"
|
||||
data-testid={`mobile-roadmap-edit-${roadmap.id}`}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(roadmap.id);
|
||||
}}
|
||||
title="Delete roadmap"
|
||||
aria-label="Delete roadmap"
|
||||
data-testid={`mobile-roadmap-delete-${roadmap.id}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Roadmap Header (shown when roadmap is selected) ────────────
|
||||
|
||||
function MobileRoadmapHeader({
|
||||
roadmapTitle,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: {
|
||||
roadmapTitle: string;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="roadmaps-view__mobile-header" data-testid="roadmaps-view__mobile-header">
|
||||
<button
|
||||
className="roadmaps-view__mobile-back-btn"
|
||||
onClick={onBack}
|
||||
title="Back to roadmap list"
|
||||
aria-label="Back to roadmap list"
|
||||
data-testid="mobile-back-btn"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h2 className="roadmaps-view__mobile-header-title">{roadmapTitle}</h2>
|
||||
<div className="roadmaps-view__mobile-header-actions">
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn"
|
||||
onClick={onCreate}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="mobile-header-create-btn"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn"
|
||||
onClick={onEdit}
|
||||
title="Edit roadmap"
|
||||
aria-label="Edit roadmap"
|
||||
data-testid="mobile-header-edit-btn"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
||||
onClick={onDelete}
|
||||
title="Delete roadmap"
|
||||
aria-label="Delete roadmap"
|
||||
data-testid="mobile-header-delete-btn"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Milestone Card ───────────────────────────────────────────────────
|
||||
|
||||
function MilestoneCard({
|
||||
@@ -1219,6 +1404,8 @@ function CreateFeatureForm({
|
||||
// ── Main Component ────────────────────────────────────────────────────
|
||||
|
||||
export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
const isMobile = useViewportMode() === "mobile";
|
||||
|
||||
const {
|
||||
roadmaps,
|
||||
selectedRoadmapId,
|
||||
@@ -1294,8 +1481,8 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
description: "",
|
||||
});
|
||||
|
||||
// Mobile sidebar state
|
||||
const [mobileSelectedRoadmapId, setMobileSelectedRoadmapId] = useState<string | null>(null);
|
||||
// Mobile roadmap list create form state
|
||||
const [mobileShowCreateForm, setMobileShowCreateForm] = useState(false);
|
||||
|
||||
// Milestone drag-and-drop state
|
||||
const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
|
||||
@@ -1896,49 +2083,83 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
|
||||
return (
|
||||
<div className="roadmaps-view">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
||||
<div className="roadmaps-view__sidebar-header">
|
||||
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
||||
<button
|
||||
className="roadmaps-view__add-btn"
|
||||
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="create-roadmap-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
|
||||
{isMobile && !effectiveSelectedRoadmapId && (
|
||||
<MobileRoadmapList
|
||||
roadmaps={roadmaps}
|
||||
selectedRoadmapId={effectiveSelectedRoadmapId}
|
||||
onSelect={(id) => selectRoadmap(id)}
|
||||
onCreate={() => setMobileShowCreateForm(true)}
|
||||
onEdit={handleStartRoadmapEdit}
|
||||
onDelete={handleDeleteRoadmap}
|
||||
onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||
showCreateForm={mobileShowCreateForm}
|
||||
onCancelCreate={() => setMobileShowCreateForm(false)}
|
||||
onSaveCreate={async (input) => {
|
||||
await handleCreateRoadmap(input);
|
||||
setMobileShowCreateForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createForm.type === "roadmap" && (
|
||||
<CreateRoadmapForm
|
||||
onSave={handleCreateRoadmap}
|
||||
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
||||
/>
|
||||
)}
|
||||
{/* Desktop sidebar (hidden on mobile) */}
|
||||
{!isMobile && (
|
||||
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
||||
<div className="roadmaps-view__sidebar-header">
|
||||
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
||||
<button
|
||||
className="roadmaps-view__add-btn"
|
||||
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="create-roadmap-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="roadmaps-view__sidebar-list">
|
||||
{roadmaps.length === 0 ? (
|
||||
<p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
|
||||
) : (
|
||||
roadmaps.map((roadmap) => (
|
||||
<RoadmapItem
|
||||
key={roadmap.id}
|
||||
roadmap={roadmap}
|
||||
isSelected={roadmap.id === effectiveSelectedRoadmapId}
|
||||
onSelect={() => selectRoadmap(roadmap.id)}
|
||||
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
||||
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
||||
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||
/>
|
||||
))
|
||||
{createForm.type === "roadmap" && (
|
||||
<CreateRoadmapForm
|
||||
onSave={handleCreateRoadmap}
|
||||
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="roadmaps-view__sidebar-list">
|
||||
{roadmaps.length === 0 ? (
|
||||
<p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
|
||||
) : (
|
||||
roadmaps.map((roadmap) => (
|
||||
<RoadmapItem
|
||||
key={roadmap.id}
|
||||
roadmap={roadmap}
|
||||
isSelected={roadmap.id === effectiveSelectedRoadmapId}
|
||||
onSelect={() => selectRoadmap(roadmap.id)}
|
||||
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
||||
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
||||
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
||||
{/* Mobile header when roadmap is selected */}
|
||||
{isMobile && effectiveSelectedRoadmapId && (
|
||||
<MobileRoadmapHeader
|
||||
roadmapTitle={selectedRoadmap?.title || "Untitled Roadmap"}
|
||||
onBack={() => selectRoadmap(null)}
|
||||
onEdit={() => {
|
||||
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
|
||||
}}
|
||||
onDelete={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
|
||||
onCreate={() => setMobileShowCreateForm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!effectiveSelectedRoadmapId ? (
|
||||
<div className="roadmaps-view__empty-main">
|
||||
<p>Select a roadmap from the sidebar to view its milestones.</p>
|
||||
|
||||
@@ -44,8 +44,28 @@ vi.mock("lucide-react", () => ({
|
||||
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
|
||||
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
|
||||
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
|
||||
ArrowLeft: (props: unknown) => <span data-testid="arrow-left-icon" {...props}>ArrowLeft</span>,
|
||||
ChevronLeft: (props: unknown) => <span data-testid="chevron-left-icon" {...props}>ChevronLeft</span>,
|
||||
}));
|
||||
|
||||
// Viewport mode mock helper
|
||||
function mockViewport(mode: "mobile" | "desktop") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const isMobileQuery = query === "(max-width: 768px)";
|
||||
const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)";
|
||||
return {
|
||||
matches: mode === "mobile" ? isMobileQuery : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const mockRoadmaps: Roadmap[] = [
|
||||
{
|
||||
id: "RM-001",
|
||||
@@ -108,6 +128,7 @@ const mockAddToast = vi.fn();
|
||||
describe("RoadmapsView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewport("desktop");
|
||||
(api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmaps);
|
||||
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmapHierarchy);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
@@ -646,4 +667,181 @@ describe("RoadmapsView", () => {
|
||||
expect(editButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mobile roadmap controls", () => {
|
||||
beforeEach(() => {
|
||||
mockViewport("mobile");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockViewport("desktop");
|
||||
});
|
||||
|
||||
it("shows mobile roadmap list when no roadmap selected on mobile", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument();
|
||||
expect(screen.getByText("Q3 Roadmap")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows mobile roadmap items when roadmaps exist on mobile", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-002")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can select a roadmap from mobile list", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on a roadmap item
|
||||
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||
|
||||
// Should show the mobile header with the roadmap title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("Q2 Roadmap", { selector: ".roadmaps-view__mobile-header-title" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("mobile back button deselects roadmap", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a roadmap
|
||||
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click back button
|
||||
fireEvent.click(screen.getByTestId("mobile-back-btn"));
|
||||
|
||||
// Should show mobile list again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("mobile create button shows create form", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click create button
|
||||
fireEvent.click(screen.getByTestId("mobile-create-roadmap-btn"));
|
||||
|
||||
// Should show create form
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("create-roadmap-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("create-roadmap-title")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("mobile can create roadmap via form", async () => {
|
||||
const newRoadmap = {
|
||||
id: "RM-003",
|
||||
title: "New Roadmap",
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
};
|
||||
(api.createRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(newRoadmap);
|
||||
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click create button
|
||||
fireEvent.click(screen.getByTestId("mobile-create-roadmap-btn"));
|
||||
|
||||
// Fill in the form using userEvent for better React integration
|
||||
await waitFor(() => {
|
||||
const titleInput = screen.getByTestId("create-roadmap-title");
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByTestId("create-roadmap-title");
|
||||
await userEvent.type(titleInput, "New Roadmap");
|
||||
|
||||
// Submit the form using fireEvent.submit
|
||||
const form = screen.getByTestId("create-roadmap-form").querySelector("form");
|
||||
expect(form).toBeTruthy();
|
||||
fireEvent.submit(form!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createRoadmap).toHaveBeenCalledWith(
|
||||
{ title: "New Roadmap" },
|
||||
undefined
|
||||
);
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Roadmap created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("mobile edit and delete buttons are visible on roadmap items", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Edit and delete buttons should be visible (not hidden behind hover on mobile)
|
||||
expect(screen.getByTestId("mobile-roadmap-edit-RM-001")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-roadmap-delete-RM-001")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-roadmap-export-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state on mobile when no roadmaps", async () => {
|
||||
(api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("No roadmaps yet.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-list").textContent).toContain("Create Roadmap");
|
||||
});
|
||||
|
||||
it("mobile header shows action buttons when roadmap selected", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a roadmap
|
||||
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Action buttons should be visible in header
|
||||
expect(screen.getByTestId("mobile-header-create-btn")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-header-edit-btn")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-header-delete-btn")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-back-btn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32134,10 +32134,217 @@ html .column.drag-over * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Roadmap List */
|
||||
.roadmaps-view__mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-list-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-add-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-add-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-create-form {
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-list-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item--active {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-item-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-action-btn:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-action-btn--danger:hover {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-2xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile Roadmap Header (shown when roadmap selected) */
|
||||
.roadmaps-view__mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-back-btn:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-back-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-header-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.roadmaps-view__mobile-header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile milestone lanes */
|
||||
.roadmaps-view__milestone-lanes {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone {
|
||||
@@ -32166,6 +32373,16 @@ html .column.drag-over * {
|
||||
.roadmap-suggestion-card {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Mobile roadmap header section */
|
||||
.roadmaps-view__roadmap-header {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Mobile create form on desktop sidebar for roadmaps - ensure proper sizing */
|
||||
.roadmaps-view__create-form {
|
||||
margin: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user