feat(widgets): unify data table behavior

This commit is contained in:
Thomas Camlong committed 2026-07-31 12:25:41 +02:00
1 parent ce75171a7c
commit e63618d00d
11 files changed
+176 -286

No files matched your search

@@ -23,7 +23,7 @@ Select a system row to open the complete statistics view, including system, file
### Interactions
- **Reorder columns** by dragging their headers
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
@@ -40,7 +40,7 @@ import dockerTable from './img/table.png';
### Interactions
Drag column headers to reorder them and drag a header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
+1 -1
View File
@@ -44,7 +44,7 @@ import contextMenuImg from './img/context-menu.png';
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display via the widget options panel
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
@@ -1,11 +1,8 @@
"use client";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
@@ -28,6 +25,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
@@ -179,7 +177,7 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden" }}>
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
@@ -387,15 +385,9 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
</div>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fz={size.fontSize}
records={sortedSystems}
columns={effectiveColumns}
@@ -403,17 +395,7 @@ export default function BeszelSystemTableWidget({
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
storeColumnsKey={storeKey}
textSelectionDisabled
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
@@ -1,47 +0,0 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.beszel-table,
.beszel-table .mantine-datatable-table,
.beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot,
.beszel-table th,
.beszel-table td {
background-color: transparent !important;
}
.beszel-table .mantine-datatable-table tr {
background-color: transparent;
}
.beszel-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.beszel-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.beszel-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.beszel-table td .mantine-Text-root,
.beszel-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
@@ -0,0 +1,52 @@
.homarr-data-table,
.homarr-data-table .mantine-datatable-table,
.homarr-data-table .mantine-datatable-table thead,
.homarr-data-table .mantine-datatable-table tbody,
.homarr-data-table .mantine-datatable-table tfoot,
.homarr-data-table th,
.homarr-data-table td {
background-color: transparent !important;
}
.homarr-data-table .mantine-datatable-table tr {
background-color: transparent;
}
.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 {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.homarr-data-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
opacity: 0;
transition: opacity 150ms ease-out;
}
.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,
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
opacity: 1;
}
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
cursor: default;
}
@@ -0,0 +1,57 @@
"use client";
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
type HomarrDataTableProps<T> = DataTableProps<T> & {
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
};
export function HomarrDataTable<T>({
isEditMode,
cellPadding,
rowCursor = "auto",
className,
defaultColumnProps,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
className,
]
.filter(Boolean)
.join(" ");
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}
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 }}
/>
);
}
+18 -30
View File
@@ -1,9 +1,6 @@
"use client";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import {
IconBrandDocker,
@@ -17,7 +14,6 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -33,6 +29,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
@@ -51,6 +48,7 @@ interface ContainerActionHandlers {
}
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
@@ -196,7 +194,6 @@ export default function DockerWidget({
}: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const tWidget = useScopedI18n("widget.dockerContainers");
const router = useRouter();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const board = useOptionalBoard();
const { data: session } = useSession();
@@ -222,8 +219,8 @@ export default function DockerWidget({
[removeContainer, restartContainer, startContainer, stopContainer],
);
const handleOpenLogs = useCallback(
(container: DockerContainer) => router.push(createContainerLogsPath(container)),
[router],
(container: DockerContainer) => window.location.assign(createContainerLogsPath(container)),
[],
);
const handleAddToHomarr = useCallback(
(container: DockerContainer) => openModal({ selectedContainers: [container] }),
@@ -317,35 +314,19 @@ export default function DockerWidget({
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
rowCursor="default"
fetching={isFetching && containers.length === 0}
loaderBackgroundBlur={2}
fz={width < 400 ? "xs" : "sm"}
records={sortedContainers}
noRecordsText={tWidget("empty.noContainers")}
columns={effectiveColumns}
storeColumnsKey={storeKey}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
height="100%"
className="docker-containers-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: width < 400 ? "2px 8px" : "4px 8px" }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
if (contextMenu) closeContextMenu();
@@ -414,7 +395,7 @@ function ContainerMenuButton({
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
<ContainerActionItems container={container} handlers={handlers} onClose={close} />
</Menu.Dropdown>
</Menu>
@@ -442,7 +423,9 @@ function ContainerActionItems({
return (
<>
<Menu.Label>{container.name}</Menu.Label>
<Menu.Label title={container.name} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{container.name}
</Menu.Label>
<Menu.Item
leftSection={<IconFileText size={14} />}
onClick={() => {
@@ -521,7 +504,12 @@ function ContainerContextMenu({
<Menu.Target>
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
</Menu.Target>
<Menu.Dropdown onClick={(event) => event.stopPropagation()}>
<Menu.Dropdown
w={containerMenuWidth}
miw={containerMenuWidth}
maw={containerMenuWidth}
onClick={(event) => event.stopPropagation()}
>
<ContainerActionItems container={state.container} handlers={handlers} onClose={onClose} />
</Menu.Dropdown>
</Menu>
-45
View File
@@ -1,45 +0,0 @@
/* Keep the table transparent so the board card controls the surface. */
.docker-containers-table,
.docker-containers-table .mantine-datatable-table,
.docker-containers-table .mantine-datatable-table thead,
.docker-containers-table .mantine-datatable-table tbody,
.docker-containers-table .mantine-datatable-table tfoot,
.docker-containers-table th,
.docker-containers-table td {
background-color: transparent !important;
}
.docker-containers-table .mantine-datatable-table tr {
background-color: transparent;
}
.docker-containers-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.docker-containers-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.docker-containers-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docker-containers-table td .mantine-Text-root,
.docker-containers-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.docker-containers-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.docker-containers-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
+41 -90
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Avatar,
@@ -41,10 +41,11 @@ import {
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
@@ -52,6 +53,8 @@ import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
dayjs.extend(relativeTime);
@@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
@@ -288,6 +300,9 @@ export default function DownloadClientsWidget({
width,
}: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds],
@@ -324,17 +339,11 @@ export default function DownloadClientsWidget({
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
const persistOption = useCallback(
(newOptions: Partial<Record<string, string>>) => {
(newOptions: Partial<Pick<typeof options, "columnOrder" | "columnWidths">>) => {
setOptions({ newOptions });
if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[setOptions, boardId, itemId, saveItemOptions],
);
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
const savedWidths = useMemo(
() => parseJsonOption(options.columnWidths, {} as Record<string, number>),
[options.columnWidths],
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
@@ -659,54 +668,15 @@ export default function DownloadClientsWidget({
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]);
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<ExtendedDownloadClientItem>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })));
}
// ponytail: defer hydrated flag so the persist effect skips the restore echo
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "downloads",
onLayoutChange: persistOption,
});
const handleContextMenu = useCallback(
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
@@ -761,9 +731,6 @@ export default function DownloadClientsWidget({
);
}
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
@@ -781,35 +748,19 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<DataTable
style={{ pointerEvents: tablePointerEvents }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && currentItems.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => `${record.integration.id}:${record.id}`}
height="100%"
className="downloads-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
-48
View File
@@ -1,51 +1,3 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.downloads-table,
.downloads-table .mantine-datatable-table,
.downloads-table .mantine-datatable-table thead,
.downloads-table .mantine-datatable-table tbody,
.downloads-table .mantine-datatable-table tfoot,
.downloads-table th,
.downloads-table td {
background-color: transparent !important;
}
.downloads-table .mantine-datatable-table tr {
background-color: transparent;
}
.downloads-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.downloads-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.downloads-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.downloads-table td .mantine-Text-root,
.downloads-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.downloads-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion styling */
.downloads-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;