fix widget focus interactions

This commit is contained in:
Thomas Camlong committed 2026-08-03 12:15:12 +02:00
1 parent 96e92485a5
commit 09e9cd4388
21 files changed
+119 -28

No files matched your search

@@ -4,8 +4,6 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -36,8 +34,6 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,8 +4,6 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -17,14 +15,14 @@ import { beszelIntegration } from "@site/docs/integrations/beszel";
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
In compact mode, select a system row to open the complete statistics view. In advanced mode, selecting a row focuses an inline one-hour view with system, filesystem, network, and Docker charts.
Select a system row to open the complete statistics view.
### Interactions
- **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 or focus system statistics** by selecting a row, depending on the display mode
- **Open system statistics** by selecting a row
### Supported Integrations
@@ -37,8 +35,6 @@ In compact mode, select a system row to open the complete statistics view. In ad
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -26,6 +26,7 @@ It shows playback progress, pause state, time remaining, and whether each sessio
Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
### Screenshots
@@ -35,7 +35,7 @@
.surfaceHostAdvanced {
position: fixed;
inset: 0;
z-index: calc(var(--mantine-z-index-modal) - 1);
z-index: calc(var(--mantine-z-index-popover) - 1);
width: auto;
height: auto;
pointer-events: none;
@@ -201,7 +201,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="calc(var(--mantine-z-index-modal) - 2)"
zIndex="calc(var(--mantine-z-index-popover) - 2)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
@@ -0,0 +1,30 @@
const canScrollVertically = (element: HTMLElement, delta: number) => {
if (element.scrollHeight <= element.clientHeight) return false;
const overflowY = getComputedStyle(element).overflowY;
if (overflowY !== "auto" && overflowY !== "scroll") return false;
return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight;
};
export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => {
if (delta === 0) return false;
let current = target instanceof HTMLElement ? target : null;
while (current && root.contains(current)) {
if (canScrollVertically(current, delta)) {
current.scrollTop += delta;
return true;
}
if (current === root) break;
current = current.parentElement;
}
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
(element) => canScrollVertically(element, delta),
);
if (!viewport) return false;
viewport.scrollTop += delta;
return true;
};
@@ -1,4 +1,4 @@
import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import type { CSSProperties, KeyboardEvent, MutableRefObject, WheelEvent } from "react";
import { useEffect, useRef } from "react";
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
import { useElementSize, useViewportSize } from "@mantine/hooks";
@@ -12,13 +12,20 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetDefinition } from "@homarr/widgets";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
widgetImports,
} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -48,7 +55,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const supportsAdvancedFocus = item.kind !== "app";
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(
widgetImports[item.kind].definition as WidgetDefinition,
);
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
@@ -81,6 +90,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
event.preventDefault();
openAdvanced(true);
};
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
if (!isAdvanced || !event.shiftKey) return;
const delta = event.deltaY || event.deltaX;
if (!redirectShiftWheel(event.currentTarget, event.target, delta)) return;
event.preventDefault();
};
return (
<>
@@ -128,6 +143,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
id={isAdvanced ? advancedViewId : undefined}
role={isAdvanced ? "region" : undefined}
aria-label={isAdvanced ? advancedViewLabel : undefined}
data-advanced-focus-surface={isAdvanced || undefined}
onWheel={handleWheel}
radius={board.itemRadius}
p={isAdvanced ? undefined : 0}
h={isAdvanced ? undefined : "100%"}
@@ -33,6 +33,7 @@ import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
reduceWidgetOptionsWithDefaultValues,
supportsAdvancedFocus,
widgetImports,
} from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -70,6 +71,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition as WidgetDefinition);
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
@@ -250,7 +252,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{item.kind !== "app" && (
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
+12
View File
@@ -115,6 +115,18 @@ describe("Board advanced interactions", () => {
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
0,
);
await advancedSurface.evaluate((element) => {
const probe = document.createElement("div");
probe.dataset.shiftScrollProbe = "true";
probe.style.cssText = "height:80px;overflow-y:auto";
probe.innerHTML = '<div style="height:600px"></div>';
element.append(probe);
});
const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe]");
await page.keyboard.down("Shift");
await scrollProbe.dispatchEvent("wheel", { shiftKey: true, deltaX: 120, deltaY: 0 });
await page.keyboard.up("Shift");
expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await page.keyboard.press("Escape");
await expect(advancedSurface).toBeHidden();
await expect(widget).toBeFocused();
@@ -1,7 +1,7 @@
export interface StreamSession {
sessionId: string;
sessionName: string;
user: {
user?: {
userId: string;
username: string;
profilePictureUrl: string | null;
+1
View File
@@ -2891,6 +2891,7 @@
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
"unknownUser": "Unknown user",
"name": "Name",
"id": "Id",
"status": "Status",
+1
View File
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
supportsAdvancedFocus: false,
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
@@ -72,7 +72,7 @@ export default function AudioStatsWidget({
{currentlyPlaying.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"}
</Text>
</Stack>
<Badge
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
supportsAdvancedFocus: false,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
@@ -1,4 +1,4 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { Box, getDefaultZIndex, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
@@ -28,6 +28,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
{formatPercent(system.disk)}
</Text>
<HoverCard
zIndex={getDefaultZIndex("popover") - 3}
position="right"
withArrow
shadow="md"
@@ -61,7 +61,7 @@ const TOOLTIP_Z_INDEX = getDefaultZIndex("modal");
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0, isAdvanced: false });
const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
@@ -72,9 +72,10 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
const isAdvanced = Boolean(document.elementFromPoint(mx, my)?.closest("[data-advanced-focus-surface]"));
if (!el) {
setPos({ x: mx + MARGIN, y: my });
setPos({ x: mx + MARGIN, y: my, isAdvanced });
return;
}
@@ -89,7 +90,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
setPos({ x, y, isAdvanced });
});
}, []);
@@ -122,7 +123,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: TOOLTIP_Z_INDEX,
zIndex: pos.isAdvanced ? getDefaultZIndex("popover") : TOOLTIP_Z_INDEX,
pointerEvents: "none",
};
+28 -1
View File
@@ -3,7 +3,15 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
import { definition as appDefinition } from "./app";
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
setWidgetRuntimeQueries,
supportsAdvancedFocus,
} from "./definition";
const togglePolling = () => undefined;
@@ -36,3 +44,22 @@ describe("getWidgetQueryKeys", () => {
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual([{ path: ["widget", "calendar", "findAllEvents"], input }]);
});
});
describe("supportsAdvancedFocus", () => {
const definition = { supportsAdvancedFocus: undefined } as Parameters<typeof supportsAdvancedFocus>[0];
test("defaults to enabled", () => {
expect(supportsAdvancedFocus(definition)).toBe(true);
});
test("allows widgets to opt out", () => {
expect(supportsAdvancedFocus({ ...definition, supportsAdvancedFocus: false })).toBe(false);
});
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
"keeps non-enhanced widgets compact",
(widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
},
);
});
+3
View File
@@ -86,6 +86,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
supportsAdvancedFocus?: boolean;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
queryMatcher?: WidgetQueryMatcher;
@@ -109,6 +110,8 @@ export interface WidgetDefinition {
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export const supportsAdvancedFocus = (definition: WidgetDefinition) => definition.supportsAdvancedFocus !== false;
export const getWidgetQueryKeys = (definition: {
kind: string;
queryKey?: QueryKey;
+1
View File
@@ -78,6 +78,7 @@ export {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
normalizeWidgetQuery,
supportsAdvancedFocus,
widgetQueryValueEquals,
} from "./definition";
export type { WidgetComponentProps };
@@ -81,10 +81,10 @@ export default function MediaServerWidget({
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user.profilePictureUrl} />
<Avatar size={28} src={row.original.user?.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user.username}
{row.original.user?.username ?? t("items.unknownUser")}
</Text>
<Text
size="10px"
@@ -415,8 +415,8 @@ const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
itemKey={t("user")}
value={
<Group gap="sm" align="center">
<Avatar size="sm" src={innerProps.item.user.profilePictureUrl} />{" "}
<Text>{innerProps.item.user.username}</Text>
<Avatar size="sm" src={innerProps.item.user?.profilePictureUrl} />{" "}
<Text>{innerProps.item.user?.username ?? t("unknownUser")}</Text>
</Group>
}
/>