* 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>
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import "./homarr-data-table.css";
|
|
|
|
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";
|
|
};
|
|
|
|
export function HomarrDataTable<T>({
|
|
isEditMode,
|
|
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,
|
|
className,
|
|
]
|
|
.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>
|
|
withTableBorder={false}
|
|
borderRadius={0}
|
|
highlightOnHover
|
|
striped="odd"
|
|
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
|
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
|
verticalAlign="center"
|
|
loaderBackgroundBlur={2}
|
|
textSelectionDisabled
|
|
height="100%"
|
|
{...props}
|
|
columns={fittedColumns}
|
|
onMouseDownCapture={handleMouseDownCapture}
|
|
className={mergedClassName}
|
|
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
|
|
defaultColumnProps={{
|
|
...defaultColumnProps,
|
|
noWrap: defaultColumnProps?.noWrap ?? true,
|
|
draggable: !isEditMode,
|
|
resizable: !isEditMode,
|
|
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
|
}}
|
|
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
|
|
/>
|
|
);
|
|
}
|