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:
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,
|
||||
|
||||
Reference in new issue
Block a user