feat(frontend): stack TaskBoard columns on mobile
Mobile-first layout for TaskBoard: stack Inbox/In Progress/Review/Done vertically to avoid horizontal scroll, while retaining the horizontal kanban layout on larger screens. Includes component tests and frontend README notes for mobile validation.
This commit is contained in:
@@ -108,6 +108,16 @@ It will:
|
|||||||
- add `Authorization: Bearer <token>` automatically from local mode token or Clerk session
|
- add `Authorization: Bearer <token>` automatically from local mode token or Clerk session
|
||||||
- parse errors into an `ApiError` with status + parsed response body
|
- parse errors into an `ApiError` with status + parsed response body
|
||||||
|
|
||||||
|
## Mobile / responsive UI validation
|
||||||
|
|
||||||
|
When changing UI intended to be mobile-ready, validate in Chrome (or similar) using the device toolbar at common widths (e.g. **320px**, **375px**, **768px**).
|
||||||
|
|
||||||
|
Quick checklist:
|
||||||
|
- No horizontal scroll
|
||||||
|
- Primary actions reachable without precision taps
|
||||||
|
- Focus rings visible when tabbing
|
||||||
|
- Modals/popovers not clipped
|
||||||
|
|
||||||
## Common commands
|
## Common commands
|
||||||
|
|
||||||
From `frontend/`:
|
From `frontend/`:
|
||||||
|
|||||||
51
frontend/src/components/organisms/TaskBoard.test.tsx
Normal file
51
frontend/src/components/organisms/TaskBoard.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { TaskBoard } from "./TaskBoard";
|
||||||
|
|
||||||
|
describe("TaskBoard", () => {
|
||||||
|
it("uses a mobile-first stacked layout (no horizontal scroll) with responsive kanban columns on larger screens", () => {
|
||||||
|
render(
|
||||||
|
<TaskBoard
|
||||||
|
tasks={[
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
title: "Inbox item",
|
||||||
|
status: "inbox",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const board = screen.getByTestId("task-board");
|
||||||
|
|
||||||
|
expect(board.className).toContain("overflow-x-hidden");
|
||||||
|
expect(board.className).toContain("sm:overflow-x-auto");
|
||||||
|
expect(board.className).toContain("grid-cols-1");
|
||||||
|
expect(board.className).toContain("sm:grid-flow-col");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only sticks column headers on larger screens (avoids weird stacked sticky headers on mobile)", () => {
|
||||||
|
render(
|
||||||
|
<TaskBoard
|
||||||
|
tasks={[
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
title: "Inbox item",
|
||||||
|
status: "inbox",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = screen
|
||||||
|
.getByRole("heading", { name: "Inbox" })
|
||||||
|
.closest(".column-header");
|
||||||
|
expect(header?.className).toContain("sm:sticky");
|
||||||
|
expect(header?.className).toContain("sm:top-0");
|
||||||
|
// Ensure we didn't accidentally keep unscoped sticky behavior.
|
||||||
|
expect(header?.className).not.toContain("sticky top-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -334,7 +334,13 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={boardRef}
|
ref={boardRef}
|
||||||
className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"
|
data-testid="task-board"
|
||||||
|
className={cn(
|
||||||
|
// Mobile-first: stack columns vertically to avoid horizontal scrolling.
|
||||||
|
"grid grid-cols-1 gap-4 overflow-x-hidden pb-6",
|
||||||
|
// Desktop/tablet: switch back to horizontally scrollable kanban columns.
|
||||||
|
"sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnTasks = grouped[column.status] ?? [];
|
const columnTasks = grouped[column.status] ?? [];
|
||||||
@@ -385,18 +391,19 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
<div
|
<div
|
||||||
key={column.title}
|
key={column.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"kanban-column min-h-[calc(100vh-260px)]",
|
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
|
||||||
|
"kanban-column min-h-0",
|
||||||
|
// On larger screens, keep columns tall to reduce empty space during drag.
|
||||||
|
"sm:min-h-[calc(100vh-260px)]",
|
||||||
activeColumn === column.status &&
|
activeColumn === column.status &&
|
||||||
!readOnly &&
|
!readOnly &&
|
||||||
"ring-2 ring-slate-200",
|
"ring-2 ring-slate-200",
|
||||||
)}
|
)}
|
||||||
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||||
onDragLeave={
|
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
||||||
readOnly ? undefined : handleDragLeave(column.status)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
||||||
@@ -473,9 +480,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
onClick={() => onTaskSelect?.(task)}
|
onClick={() => onTaskSelect?.(task)}
|
||||||
draggable={!readOnly && !task.is_blocked}
|
draggable={!readOnly && !task.is_blocked}
|
||||||
isDragging={draggingId === task.id}
|
isDragging={draggingId === task.id}
|
||||||
onDragStart={
|
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
||||||
readOnly ? undefined : handleDragStart(task)
|
|
||||||
}
|
|
||||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user