From 27525d8f7232e9bd9f46ef9d4c4ec4889cff6e45 Mon Sep 17 00:00:00 2001 From: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:38:05 +0100 Subject: [PATCH] fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 --- src/ui/components/data-view.tsx | 64 ++++++++-- src/ui/tests/components/data-view.test.tsx | 132 ++++++++++++++++++++- src/ui/tests/shell/TitleBar.test.tsx | 3 +- 3 files changed, 185 insertions(+), 14 deletions(-) diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx index d61f65625..4d317a196 100644 --- a/src/ui/components/data-view.tsx +++ b/src/ui/components/data-view.tsx @@ -14,11 +14,32 @@ export interface DataColumn { /** Any grid track: "1fr", "120px", "minmax(0,2fr)". */ width: string; cell: (item: T) => React.ReactNode; - /** Dropped below md, for columns that are nice rather than necessary. */ + /** Hidden when the table itself is narrower than md (48rem) / lg (64rem). */ hideBelow?: "md" | "lg"; align?: "start" | "end"; } +/** Tracks for the columns visible at each width, so hidden ones take no space. */ +function listColumnTemplates(columns: DataColumn[]): { + base: string; + md: string; + lg: string; +} { + return { + base: + columns + .filter((column) => column.hideBelow === undefined) + .map((column) => column.width) + .join(" ") || "none", + md: + columns + .filter((column) => column.hideBelow !== "lg") + .map((column) => column.width) + .join(" ") || "none", + lg: columns.map((column) => column.width).join(" ") || "none", + }; +} + export interface GridColumns { base?: number; md?: number; @@ -52,6 +73,9 @@ const XL_COLS: Record = { 4: "xl:grid-cols-4", }; +const LIST_GRID_CLASS = + "grid grid-cols-[var(--data-view-cols)] @min-[48rem]:grid-cols-[var(--data-view-cols-md)] @min-[64rem]:grid-cols-[var(--data-view-cols-lg)] gap-x-2 px-3"; + function gridClass(columns: GridColumns): string { return cn( COLS[columns.base ?? 1], @@ -61,6 +85,12 @@ function gridClass(columns: GridColumns): string { ); } +function columnVisibility(hideBelow?: "md" | "lg"): string { + if (hideBelow === "md") return "hidden @min-[48rem]:block"; + if (hideBelow === "lg") return "hidden @min-[64rem]:block"; + return ""; +} + /** One collection as a card grid or a dense row table. */ export function DataView({ items, @@ -120,14 +150,26 @@ export function DataView({ ); } - const template = listColumns.map((c) => c.width).join(" "); - const rowHeight = density === "compact" ? "h-7" : "h-9"; + const templates = listColumnTemplates(listColumns); + const rowHeight = density === "compact" ? "min-h-7" : "min-h-9"; return ( -
+
{listColumns.map((column) => ( ({ className={cn( "truncate py-2", column.align === "end" && "text-right", - column.hideBelow === "md" && "hidden md:block", - column.hideBelow === "lg" && "hidden lg:block", + columnVisibility(column.hideBelow), )} > {column.header} @@ -152,12 +193,12 @@ export function DataView({ key={getKey(item)} onClick={onRowClick ? () => onRowClick(item) : undefined} className={cn( - "grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0", + LIST_GRID_CLASS, + "items-center border-b border-border/50 text-xs transition-colors last:border-0", rowHeight, onRowClick && "cursor-pointer", active ? "bg-accent-brand/10" : "hover:bg-muted/40", )} - style={{ gridTemplateColumns: template }} > {listColumns.map((column) => (
({ className={cn( "min-w-0 truncate", column.align === "end" && "text-right", - column.hideBelow === "md" && "hidden md:block", - column.hideBelow === "lg" && "hidden lg:block", + columnVisibility(column.hideBelow), )} > {column.cell(item)} diff --git a/src/ui/tests/components/data-view.test.tsx b/src/ui/tests/components/data-view.test.tsx index d4a8c4bc5..f7a41fe80 100644 --- a/src/ui/tests/components/data-view.test.tsx +++ b/src/ui/tests/components/data-view.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { DataView } from "@/components/data-view"; +import { DataView, type DataColumn } from "@/components/data-view"; afterEach(cleanup); @@ -75,3 +75,133 @@ describe("DataView", () => { expect(screen.getByText("nothing")).toBeTruthy(); }); }); + +describe("list grid", () => { + const listColumns: DataColumn<(typeof items)[number]>[] = [ + { key: "a", header: "A", width: "1fr", cell: (item) => `a ${item.name}` }, + { + key: "b", + header: "B", + width: "90px", + hideBelow: "md", + cell: (item) => `b ${item.name}`, + }, + { key: "c", header: "C", width: "2fr", cell: (item) => `c ${item.name}` }, + { + key: "d", + header: "D", + width: "70px", + hideBelow: "lg", + cell: (item) => `d ${item.name}`, + }, + { + key: "e", + header: "E", + width: "minmax(0,1.2fr)", + hideBelow: "md", + cell: (item) => `e ${item.name}`, + }, + ]; + + function renderList(density?: "compact", columns = listColumns) { + render( + item.id} + renderCard={() => null} + columns={{}} + listColumns={columns} + empty={null} + />, + ); + const root = screen.getByRole("table"); + const header = root.children[0] as HTMLElement; + const rows = Array.from(root.children[1].children) as HTMLElement[]; + return { root, header, rows }; + } + + it("sets container templates using only columns visible at each breakpoint", () => { + const { root } = renderList(); + + expect(root.style.getPropertyValue("--data-view-cols")).toBe("1fr 2fr"); + expect(root.style.getPropertyValue("--data-view-cols-md")).toBe( + "1fr 90px 2fr minmax(0,1.2fr)", + ); + expect(root.style.getPropertyValue("--data-view-cols-lg")).toBe( + "1fr 90px 2fr 70px minmax(0,1.2fr)", + ); + expect(root.classList.contains("@container")).toBe(true); + }); + + it("shares grid classes between the header and every body row", () => { + const { header, rows } = renderList(); + const expectedGridClasses = [ + "grid", + "grid-cols-[var(--data-view-cols)]", + "@min-[48rem]:grid-cols-[var(--data-view-cols-md)]", + "@min-[64rem]:grid-cols-[var(--data-view-cols-lg)]", + "gap-x-2", + ].sort(); + + expect(rows).toHaveLength(items.length); + for (const row of [header, ...rows]) { + const gridClasses = Array.from(row.classList).filter( + (className) => + className === "grid" || + className.includes("grid-cols-") || + className.startsWith("gap-x-"), + ); + expect(gridClasses.sort()).toEqual(expectedGridClasses); + expect(row.style.gridTemplateColumns).toBe(""); + } + }); + + it("uses container-query visibility for cells and their headers", () => { + const { header, rows } = renderList(); + + for (const row of [header, ...rows]) { + for (const index of [1, 4]) { + expect(row.children[index].classList.contains("hidden")).toBe(true); + expect( + row.children[index].classList.contains("@min-[48rem]:block"), + ).toBe(true); + } + expect(row.children[3].classList.contains("hidden")).toBe(true); + expect(row.children[3].classList.contains("@min-[64rem]:block")).toBe( + true, + ); + for (const index of [0, 2]) { + expect(row.children[index].classList.contains("hidden")).toBe(false); + } + } + }); + + it("uses a minimum height for compact rows without a fixed height", () => { + const { rows } = renderList("compact"); + + for (const row of rows) { + expect(row.classList.contains("min-h-7")).toBe(true); + expect(row.classList.contains("h-7")).toBe(false); + } + }); + + it("uses the default row minimum height when density is omitted", () => { + const { rows } = renderList(); + + for (const row of rows) { + expect(row.classList.contains("min-h-9")).toBe(true); + } + }); + + it("uses no base tracks when every column is hidden at the base width", () => { + const { root } = renderList(undefined, [ + listColumns[1], + listColumns[3], + listColumns[4], + ]); + + expect(root.style.getPropertyValue("--data-view-cols")).toBe("none"); + }); +}); diff --git a/src/ui/tests/shell/TitleBar.test.tsx b/src/ui/tests/shell/TitleBar.test.tsx index a030888e6..5075b9596 100644 --- a/src/ui/tests/shell/TitleBar.test.tsx +++ b/src/ui/tests/shell/TitleBar.test.tsx @@ -46,7 +46,8 @@ describe("TitleBar", () => { installBridge(); render(); expect(await screen.findByText("Termix")).toBeTruthy(); - expect(heightVar()).toBe("40px"); + // The offset is set in an effect that can run after the bar shows up. + await waitFor(() => expect(heightVar()).toBe("40px")); }); it("stays hidden in windows without the app-drawn bar", async () => {