Mobile Media table: stacked cards + mobile pagination (Slice 3)

Below md, the Media DataTable renders as MobileCardRow cards: title as
primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap
navigates to /files?path=... (same handleRowClick as desktop). The TanStack
column-visibility toggle is absent below md (the card picks the fields).

Pagination is preserved via a standalone MediaMobilePagination component
that mirrors DataTablePagination semantics (rows count, page-size select,
page indicator, prev/next with correct disabled states) off the raw
PaginationState. The duplication is flagged tech debt -- extracting a shared
TablePagination is a follow-up, out of scope for this slice.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props. enableRowSelection state is
vestigial (no batch consumer on either path); navigation is the correct
primary mobile interaction.

Tests: 5 new covering mobile cards + hidden column toggle + pagination +
card-tap navigation, and desktop DataTable + column toggle. matchMedia
mocked per-breakpoint. 94 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
This commit is contained in:
Developer
2026-06-26 12:43:09 +00:00
parent c447dfe68d
commit 2e3e7b3850
2 changed files with 258 additions and 27 deletions
+165 -27
View File
@@ -9,6 +9,10 @@ import type {
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -31,6 +35,7 @@ import {
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import { useIsMobile } from "../hooks/useIsMobile";
import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
@@ -75,6 +80,116 @@ function getMediaRowId(row: MediaItem): string {
return row.path;
}
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
// tech + context info a user scanning the library on a phone needs. Runtime,
// bitrate, resolution, codec etc. live on the desktop table only.
const mediaCardFields: MobileCardField<MediaItem>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{
key: "hdr",
label: "HDR",
render: (r) => r.hdr || "-",
},
{ key: "library", label: "Library", render: (r) => r.library || "-" },
{
key: "year",
label: "Year",
render: (r) => (r.year != null ? String(r.year) : "-"),
},
];
// Standalone pagination for the mobile card layout. The DataTable renders its
// own pagination internally; this mirrors that UI (rows count, page-size
// select, page indicator, prev/next) but works off the raw pagination state
// instead of a TanStack table instance. See spec R3.3.
function MediaMobilePagination({
pageIndex,
pageSize,
pageSizeOptions,
totalRows,
pageCount,
onPaginationChange,
}: {
pageIndex: number;
pageSize: number;
pageSizeOptions: number[];
totalRows: number;
pageCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
<div className="text-muted-foreground">
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPaginationChange(() => ({
pageIndex: 0,
pageSize: Number(value),
}))
}
>
<SelectTrigger
size="sm"
className="w-[70px]"
aria-label="Rows per page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-muted-foreground">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() =>
onPaginationChange((prev) => ({
...prev,
pageIndex: Math.max(0, prev.pageIndex - 1),
}))
}
disabled={pageIndex <= 0}
aria-label="Previous page"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
onPaginationChange((prev) => ({
...prev,
pageIndex: prev.pageIndex + 1,
}))
}
disabled={pageIndex >= pageCount - 1}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
</div>
);
}
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
@@ -178,6 +293,7 @@ export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen();
const isMobile = useIsMobile();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
@@ -532,33 +648,55 @@ export function Media() {
</p>
)}
{status?.exists && (
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
/>
</div>
)}
{status?.exists &&
(isMobile ? (
<div className="rounded-lg border bg-card">
<div className="p-4">
<MobileCardRow
rows={queryResult?.items ?? []}
fields={mediaCardFields}
getRowId={getMediaRowId}
onRowClick={handleRowClick}
/>
</div>
{queryResult && (
<MediaMobilePagination
pageIndex={pageIndex}
pageSize={pageSize}
pageSizeOptions={[50, 100, 200]}
totalRows={total}
pageCount={totalPages}
onPaginationChange={handlePaginationChange}
/>
)}
</div>
) : (
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
/>
</div>
))}
</div>
);
}
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
// so the DataTable path renders by default; mobile tests override.
beforeEach(() => {
setMatchMedia(false);
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
@@ -152,6 +156,30 @@ beforeEach(() => {
};
});
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />);
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
).toBeInTheDocument();
});
});
describe("Media (mobile card layout — slice 3)", () => {
it("renders cards with the title as primary below md", () => {
setMatchMedia(true);
render(<Media />);
// Card titles render (primary field).
expect(screen.getByText("Inception")).toBeInTheDocument();
expect(screen.getByText("Matrix")).toBeInTheDocument();
// Card field labels render (at least once per row).
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
// Desktop table headers do NOT render on mobile.
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
});
it("hides the column-visibility toggle below md", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
});
it("renders pagination controls below the cards on mobile", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Next page" }),
).toBeInTheDocument();
});
it("navigates to the file browser when a card is tapped on mobile", async () => {
setMatchMedia(true);
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("renders the DataTable (not cards) at desktop width", () => {
render(<Media />);
// Desktop column headers render.
expect(
screen.getByRole("columnheader", { name: "Title" }),
).toBeInTheDocument();
// Column-visibility toggle is present.
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
});
});