feat(v2 docker widget): Fixes column-resize squashing (#6646)

* fix(dataTable): resizing a column no longer squashes the others

mantine-datatable's built-in column resize keeps the table's total
width fixed by shrinking every other column to compensate, which
collapses columns toward zero in a narrow widget (open upstream
issue, no fix: icflorescu/mantine-datatable#discussions-757).

Replaced it with our own resize handle that only ever changes the
width of the column being dragged - the table grows and scrolls
horizontally instead. Shared via usePersistedTableLayout, so docker,
downloads, and beszel-system-table all get it.

* feat(docker): enable hold-Shift advanced focus preview

The widget already renders differently for displayMode="advanced",
but was missing supportsAdvancedFocus: true, so the board never
offered the hold-Shift preview for it - matching downloads and
media-server, which both set this flag.

* style(docker): tighten row density, decouple icon size from row height

Circular avatar, denser row/footer padding, and action icons rendered
larger than their button via absolute positioning so they don't
inflate row height (the button stays small and fixed - it repeats
once per row, so any growth there multiplies across every visible
container).

* fix(data-table): harden shared column resizing

* fix(data-table): account for board scaling

* fix(data-table): compact shared headers

* fix(data-table): fit columns to widget

* fix(downloads): simplify advanced details

* test(widgets): align Docker advanced focus

* fix(downloads): widen expanded details

---------

Co-authored-by: ajnart <thomas@ajnart.dev>
This commit is contained in:
Bergashaandajnart authored and GitHub committed 2026-08-21 15:12:31 +02:00
1 parent 9b406d5d83
commit 002381d294
13 files changed
+270 -62

No files matched your search

@@ -12,11 +12,18 @@
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table {
width: 100% !important;
max-width: 100%;
table-layout: fixed !important;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
@@ -28,6 +35,13 @@
text-overflow: ellipsis;
}
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
growing it - used to make a per-row icon look bigger without inflating row height, since the
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
.homarr-data-table td.docker-actions-cell {
overflow: visible;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
@@ -40,6 +54,25 @@
transition: opacity 150ms ease-out;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
gap: 0;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
margin: 0;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
display: none;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
width: var(--mantine-font-size-md);
height: var(--mantine-font-size-md);
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
@@ -2,10 +2,24 @@
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width === "string") {
const parsedWidth = Number.parseFloat(width);
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
}
return fallbackColumnWidth;
};
type HomarrDataTableProps<T> = DataTableProps<T> & {
columns: DataTableColumn<T>[];
groups?: never;
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
@@ -16,11 +30,78 @@ export function HomarrDataTable<T>({
cellPadding,
rowCursor = "auto",
className,
columns,
defaultColumnProps,
onMouseDownCapture,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
const removeResizeListeners = useRef<() => void>(() => undefined);
useEffect(() => () => removeResizeListeners.current(), []);
const handleMouseDownCapture = useCallback(
(event: React.MouseEvent<HTMLTableElement>) => {
onMouseDownCapture?.(event);
const target = event.target;
if (!(target instanceof Element)) return;
const handle = target.closest(".mantine-datatable-header-resizable-handle");
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
const headerRow = currentCell?.parentElement;
if (!currentCell || !headerRow) return;
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
const currentIndex = cells.indexOf(currentCell);
const nextCell = cells[currentIndex + 1];
const currentAccessor = currentCell.dataset.accessor;
const nextAccessor = nextCell?.dataset.accessor;
if (!nextCell || !currentAccessor || !nextAccessor) return;
const widths = Object.fromEntries(
cells.flatMap((cell) => {
const accessor = cell.dataset.accessor;
if (!accessor) return [];
return [[accessor, cell.getBoundingClientRect().width]];
}),
);
const currentWidth = widths[currentAccessor];
const nextWidth = widths[nextAccessor];
if (currentWidth === undefined || nextWidth === undefined) return;
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
const minimumWidth = fallbackColumnWidth * visualScale;
const startX = event.clientX;
removeResizeListeners.current();
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = Math.min(
nextWidth - minimumWidth,
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
);
setResizePreviewWidths({
...widths,
[currentAccessor]: currentWidth + delta,
[nextAccessor]: nextWidth - delta,
});
};
const handleResizeEnd = () => {
removeResizeListeners.current();
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
};
removeResizeListeners.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("blur", handleResizeEnd);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("blur", handleResizeEnd);
},
[onMouseDownCapture],
);
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
@@ -28,6 +109,26 @@ export function HomarrDataTable<T>({
]
.filter(Boolean)
.join(" ");
const fittedColumns = useMemo(() => {
const visibleColumns = columns.filter(({ hidden }) => !hidden);
const getRenderedWidth = (column: DataTableColumn<T>) =>
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
if (totalWidth <= 0) return columns;
return columns.map((column) => {
if (column.hidden) return column;
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
const fittedWidth = { width, minWidth: width, maxWidth: width };
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
return {
...column,
titleStyle: [column.titleStyle, fittedWidth],
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
};
});
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
return (
<DataTable<T>
@@ -42,6 +143,8 @@ export function HomarrDataTable<T>({
textSelectionDisabled
height="100%"
{...props}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
@@ -107,7 +107,7 @@ export const parseColumnWidths = (value: string, columnAccessors: readonly strin
}
};
export const usePersistedTableLayout = <T>({
export const usePersistedTableLayout = <T,>({
columns,
columnAccessors,
columnOrder,
+1 -1
View File
@@ -39,7 +39,6 @@ describe("supportsAdvancedFocus", () => {
beszelSystemStatsDefinition,
beszelSystemTableDefinition,
customApiDefinition,
dockerDefinition,
indexerManagerDefinition,
notebookDefinition,
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
@@ -49,6 +48,7 @@ describe("supportsAdvancedFocus", () => {
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(clockDefinition)).toBe(true);
expect(supportsAdvancedFocus(dockerDefinition)).toBe(true);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
+49 -16
View File
@@ -69,6 +69,18 @@ interface ContainerActionHandlers {
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
// The *button* sizes stay small and fixed - row height is set by the tallest cell, and the
// actions button is repeated once per row, so any growth here multiplies across every row and
// tanks how many containers fit on screen. The *icon* sizes are rendered larger than their
// button via absolute positioning (see ContainerMenuButton/footer refresh below) - an
// absolutely-positioned element is removed from normal flow, so it doesn't count toward its
// ancestor's height even though it visually overflows the button. This lets the icon look
// properly sized without the button (and therefore the row) growing to match.
const rowActionButtonSize = 22;
const rowActionIconVisualSize = 32;
const footerRefreshButtonSize = 24;
const footerRefreshIconVisualSize = 30;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
@@ -83,7 +95,13 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
<Badge
size="xs"
radius="sm"
variant="light"
color={containerStateColorMap[state]}
styles={{ label: { lineHeight: 1.2 } }}
>
{t(state)}
</Badge>
);
@@ -126,16 +144,15 @@ const createColumns = (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
size={20}
radius="xl"
size={24}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
<Text size="sm" truncate>
<Text size="sm" lh={1.2} truncate>
{container.name}
</Text>
</Group>
@@ -191,8 +208,12 @@ const createColumns = (
{
accessor: "actions",
title: "",
width: 44,
width: rowActionButtonSize + 18,
textAlign: "right",
// The default `.homarr-data-table td { overflow: hidden }` (needed for name/host ellipsis
// truncation) would clip the actions icon's intentional visual overflow - opt this one
// cell out of it.
cellsClassName: "docker-actions-cell",
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
},
];
@@ -407,7 +428,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
cellPadding="2px 8px"
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
@@ -431,38 +452,43 @@ export default function DockerWidget({
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
py={2}
px={8}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
<IconBrandDocker size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
<Text size="xs" truncate>
{t("table.footer", { count: containers.length.toString() })}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
size={footerRefreshButtonSize}
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching || refreshInventory.isPending}
onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
style={{ position: "relative", overflow: "visible" }}
>
<IconRefresh size="var(--mantine-font-size-md)" />
<IconRefresh
size={footerRefreshIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Tooltip>
</Group>
@@ -492,11 +518,18 @@ function ContainerMenuButton({
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
size={rowActionButtonSize}
aria-label={t("title")}
onClick={(event) => event.stopPropagation()}
style={{ position: "relative", overflow: "visible" }}
>
<IconDots size="var(--mantine-font-size-md)" />
{/* Rendered larger than the button and pulled out of flow via absolute positioning,
so it looks properly sized without the button - and therefore the row - growing
to match (row height is set by the tallest cell, and this button repeats per row). */}
<IconDots
size={rowActionIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
+1
View File
@@ -26,6 +26,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
supportsAdvancedFocus: true,
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
+21 -13
View File
@@ -793,7 +793,9 @@ export default function DownloadClientsWidget({
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
content: ({ record, collapse }) => (
<ExpandedRow item={record} collapse={collapse} />
),
}}
onScroll={() => {
if (contextMenu) closeContextMenu();
@@ -869,7 +871,13 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
);
}
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
function ExpandedRow({
item,
collapse,
}: {
item: ExtendedDownloadClientItem;
collapse: () => void;
}) {
const t = useScopedI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
@@ -891,13 +899,13 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
thickness={5}
roundCaps
sections={[{ value: progressPercent, color: progressColor(item.state, item.progress) }]}
label={<Text ta="center" fw={700} style={{ fontSize: 10 }}>{`${progressPercent}%`}</Text>}
label={<Text size="xs" ta="center" fw={700}>{`${progressPercent}%`}</Text>}
/>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Avatar size={16} radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="xs" fw={600} truncate>
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="var(--mantine-font-size-md)" radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
{item.name}
</Text>
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
@@ -905,7 +913,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
</Badge>
</Group>
<SimpleGrid cols={3} spacing={4} verticalSpacing={2}>
<SimpleGrid cols={3} spacing="xs" verticalSpacing={4} style={{ minWidth: 0 }}>
<DetailPair
label={t("items.size.detailsTitle")}
value={`${formatBytes(item.received)} / ${formatBytes(item.size)}`}
@@ -957,17 +965,17 @@ function DetailPair({
color?: string;
}) {
return (
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", fontSize: 10 }}>
<Stack gap={0} style={{ minWidth: 0, overflow: "hidden" }}>
<Text size="xs" c="dimmed" truncate>
{label}:
</Text>
<Group gap={2} wrap="nowrap">
<Group gap={2} wrap="nowrap" style={{ minWidth: 0 }}>
{icon}
<Text size="xs" fw={500} c={color} truncate style={{ fontSize: 10 }}>
<Text size="sm" fw={500} c={color} truncate style={{ minWidth: 0 }}>
{value}
</Text>
</Group>
</Group>
</Stack>
);
}
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import {
DOWNLOAD_COLUMN_ACCESSORS,
ADVANCED_DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
@@ -25,8 +25,8 @@ describe("downloads filters", () => {
});
describe("downloads advanced columns", () => {
test("uses every column in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(DOWNLOAD_COLUMN_ACCESSORS);
test("uses summary columns in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(ADVANCED_DOWNLOAD_COLUMN_ACCESSORS);
expect(getDownloadColumnAccessors(["name"], false)).toEqual(["name"]);
});
});
+12 -1
View File
@@ -20,6 +20,17 @@ export const DOWNLOAD_COLUMN_ACCESSORS = [
"type",
] as const satisfies readonly (keyof ExtendedDownloadClientItem)[];
export const ADVANCED_DOWNLOAD_COLUMN_ACCESSORS = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"integration",
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
type DownloadColumnAccessor = (typeof DOWNLOAD_COLUMN_ACCESSORS)[number];
export function getDownloadsStatsDisplay(showCompactStats: boolean, isAdvanced: boolean) {
@@ -33,7 +44,7 @@ export function getDownloadColumnAccessors(
configuredColumns: readonly DownloadColumnAccessor[],
isAdvanced: boolean,
): readonly DownloadColumnAccessor[] {
return isAdvanced ? DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
return isAdvanced ? ADVANCED_DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
}
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(