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:
Abhimanyu Saharan
2026-02-12 08:00:21 +00:00
parent 855885afaf
commit 3b6e2d98d3
3 changed files with 75 additions and 9 deletions

View File

@@ -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/`:

View 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");
});
});

View File

@@ -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>