Mobile FileBrowser: stacked cards for file list (Slice 4)

Below md, the file table renders as MobileCardRow cards: name as primary,
plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows
navigate into the directory; file rows select for ffprobe preview). No
pagination needed (FileBrowser does not paginate).

The ext column is omitted from the card -- the extension is already visible
in the filename itself, so it's redundant on mobile and would waste card
space.

Path bar / breadcrumbs / Open / Refresh live outside the table and already
stack on mobile via existing md:flex-row. ffprobe and Jobs sections are
unaffected.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props.

Tests: 4 new covering mobile card render + dir-tap navigation + path
controls present + desktop DataTable. matchMedia mocked per-breakpoint.
98 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4).
This commit is contained in:
Developer
2026-06-26 12:53:25 +00:00
parent 2e3e7b3850
commit 2076ab76fa
2 changed files with 124 additions and 17 deletions
@@ -8,6 +8,7 @@ import type { DirectoryListing, MonitoringMachine } from "../../types";
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
setMatchMedia(false);
});
function machineFixture(
@@ -77,6 +78,30 @@ beforeEach(() => {
]);
});
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
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("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
@@ -118,3 +143,56 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
describe("FileBrowser (mobile card layout — slice 4)", () => {
it("renders cards with file/dir name as primary below md", () => {
setMatchMedia(true);
render(<FileBrowser />);
// Card titles (the 'name' field rendered as primary).
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
expect(screen.getByText("notes.txt")).toBeInTheDocument();
// Desktop table column headers must NOT render.
const headers = screen.queryAllByRole("columnheader");
expect(headers).toHaveLength(0);
});
it("tapping a directory card navigates into it", async () => {
setMatchMedia(true);
render(<FileBrowser />);
// Directory card is a button wrapping the 'movies' text.
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd
// and NO 'Selected:' segment (directories are opened, not selected).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
it("renders the path/breadcrumb controls on mobile", () => {
setMatchMedia(true);
render(<FileBrowser />);
// The 'Remote path' label and its input are part of the Browser section
// card (outside the table), so they render on both breakpoints.
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});
it("renders the DataTable at desktop width (1280px)", () => {
setMatchMedia(false);
render(<FileBrowser />);
// Desktop path: table column headers are present.
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
});
});