fix widget focus interactions
This commit is contained in:
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2891,6 +2891,7 @@
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
"user": "User",
|
||||
"unknownUser": "Unknown user",
|
||||
"name": "Name",
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user