mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
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
This commit is contained in:
1 parent
ffe757f91d
commit
27525d8f72
3 files changed
+185
-14
No files matched your search
@@ -14,11 +14,32 @@ export interface DataColumn<T> {
|
|||||||
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
|
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
|
||||||
width: string;
|
width: string;
|
||||||
cell: (item: T) => React.ReactNode;
|
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";
|
hideBelow?: "md" | "lg";
|
||||||
align?: "start" | "end";
|
align?: "start" | "end";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tracks for the columns visible at each width, so hidden ones take no space. */
|
||||||
|
function listColumnTemplates<T>(columns: DataColumn<T>[]): {
|
||||||
|
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 {
|
export interface GridColumns {
|
||||||
base?: number;
|
base?: number;
|
||||||
md?: number;
|
md?: number;
|
||||||
@@ -52,6 +73,9 @@ const XL_COLS: Record<number, string> = {
|
|||||||
4: "xl:grid-cols-4",
|
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 {
|
function gridClass(columns: GridColumns): string {
|
||||||
return cn(
|
return cn(
|
||||||
COLS[columns.base ?? 1],
|
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. */
|
/** One collection as a card grid or a dense row table. */
|
||||||
export function DataView<T>({
|
export function DataView<T>({
|
||||||
items,
|
items,
|
||||||
@@ -120,14 +150,26 @@ export function DataView<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = listColumns.map((c) => c.width).join(" ");
|
const templates = listColumnTemplates(listColumns);
|
||||||
const rowHeight = density === "compact" ? "h-7" : "h-9";
|
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("border border-border bg-card", className)} role="table">
|
<div
|
||||||
|
className={cn("@container border border-border bg-card", className)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--data-view-cols": templates.base,
|
||||||
|
"--data-view-cols-md": templates.md,
|
||||||
|
"--data-view-cols-lg": templates.lg,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
role="table"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
className={cn(
|
||||||
style={{ gridTemplateColumns: template }}
|
LIST_GRID_CLASS,
|
||||||
|
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{listColumns.map((column) => (
|
{listColumns.map((column) => (
|
||||||
<span
|
<span
|
||||||
@@ -135,8 +177,7 @@ export function DataView<T>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"truncate py-2",
|
"truncate py-2",
|
||||||
column.align === "end" && "text-right",
|
column.align === "end" && "text-right",
|
||||||
column.hideBelow === "md" && "hidden md:block",
|
columnVisibility(column.hideBelow),
|
||||||
column.hideBelow === "lg" && "hidden lg:block",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
@@ -152,12 +193,12 @@ export function DataView<T>({
|
|||||||
key={getKey(item)}
|
key={getKey(item)}
|
||||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||||
className={cn(
|
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,
|
rowHeight,
|
||||||
onRowClick && "cursor-pointer",
|
onRowClick && "cursor-pointer",
|
||||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: template }}
|
|
||||||
>
|
>
|
||||||
{listColumns.map((column) => (
|
{listColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
@@ -165,8 +206,7 @@ export function DataView<T>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 truncate",
|
"min-w-0 truncate",
|
||||||
column.align === "end" && "text-right",
|
column.align === "end" && "text-right",
|
||||||
column.hideBelow === "md" && "hidden md:block",
|
columnVisibility(column.hideBelow),
|
||||||
column.hideBelow === "lg" && "hidden lg:block",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.cell(item)}
|
{column.cell(item)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -75,3 +75,133 @@ describe("DataView", () => {
|
|||||||
expect(screen.getByText("nothing")).toBeTruthy();
|
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(
|
||||||
|
<DataView
|
||||||
|
items={items}
|
||||||
|
view="list"
|
||||||
|
density={density}
|
||||||
|
getKey={(item) => 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,8 @@ describe("TitleBar", () => {
|
|||||||
installBridge();
|
installBridge();
|
||||||
render(<TitleBar />);
|
render(<TitleBar />);
|
||||||
expect(await screen.findByText("Termix")).toBeTruthy();
|
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 () => {
|
it("stays hidden in windows without the app-drawn bar", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user