fix: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent 836a4cd46b
commit 56cc021f98
21 files changed
+163 -85

No files matched your search

@@ -54,7 +54,7 @@ export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?:
{advancedView && (
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed.
Touch devices expose a maximize control; compact-only widgets adapt in place instead.
On touch devices, use the widget menu; compact-only widgets adapt in place instead.
</p>
)}
</details>
@@ -76,34 +76,12 @@
pointer-events: none;
}
.surfaceControls {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.closeButton {
position: absolute;
z-index: 2;
pointer-events: auto;
}
.touchButton {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: none;
pointer-events: auto;
}
@media (any-pointer: coarse) {
.touchButton {
display: inline-flex;
}
}
@keyframes focus-backdrop-in {
from {
opacity: 0;
@@ -37,6 +37,8 @@ interface AdvancedFocusContextValue {
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
const HOVER_DELAY_MS = 150;
const CLOSE_DURATION_MS = 180;
const interactiveSelector =
"a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']";
const isEditableTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
@@ -131,6 +133,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const handlePointerMove = (event: PointerEvent) => {
const current = activeRef.current;
if (!shiftHeldRef.current || current?.activation !== "preview") return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
const underlyingSource = document
.elementsFromPoint(event.clientX, event.clientY)
@@ -22,14 +22,5 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (current === root) break;
current = current.parentElement;
}
// A nested control can reach its edge before the widget's main Mantine viewport.
// Hand the gesture to that viewport instead of making Shift+wheel feel stuck.
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
(element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta),
);
if (!viewport) return false;
viewport.scrollTop += delta;
return true;
return false;
};
@@ -27,11 +27,16 @@ const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scop
describe("matchesWidgetItemQuery", () => {
test("matches only the current Custom API definition", () => {
const scope = createScope({ options: { definitionId: "definition-1" } });
const input = { boardId: "board-1", itemId: "item-1", definitionId: "definition-1" };
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
true,
expect(matches("customApi", ["widget", "customApi", "getData"], input, scope)).toBe(true);
expect(
matches("customApi", ["widget", "customApi", "getData"], { ...input, definitionId: "definition-2" }, scope),
).toBe(false);
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, itemId: "item-2" }, scope)).toBe(
false,
);
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, boardId: "board-2" }, scope)).toBe(
false,
);
});
+25 -6
View File
@@ -86,6 +86,7 @@ describe("Board advanced interactions", () => {
await expect(previewSurface).toHaveCSS("animation-name", "none");
const compactBounds = await widget.boundingBox();
const advancedBounds = await previewSurface.boundingBox();
if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds");
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toBeVisible();
@@ -105,6 +106,24 @@ describe("Board advanced interactions", () => {
const otherBounds = await otherWidget.boundingBox();
expect(otherBounds).not.toBeNull();
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
await previewSurface.evaluate(
(element, position) => {
const probe = document.createElement("button");
probe.dataset.interactivePreviewProbe = "true";
probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`;
element.append(probe);
},
{
left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20,
top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20,
},
);
const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']");
await interactivePreviewProbe.hover();
await expect(previewSurface).toBeVisible();
await expect(bookmarksPreviewSurface).toBeHidden();
await interactivePreviewProbe.evaluate((element) => element.remove());
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
await expect(previewSurface).toBeHidden();
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
@@ -130,17 +149,17 @@ describe("Board advanced interactions", () => {
0,
);
await manualSurface.evaluate((element) => {
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
const probe = document.createElement("div");
probe.dataset.shiftScrollProbe = "true";
probe.style.cssText = "height:80px;overflow-y:auto";
probe.innerHTML = '<svg data-shift-scroll-target="true"></svg><div style="height:600px"></div>';
probe.addEventListener("wheel", (event) => event.stopPropagation());
element.append(probe);
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
mainViewport.innerHTML = '<div style="height:600px"></div>';
mainViewport.append(probe);
mainViewport.insertAdjacentHTML("beforeend", '<div style="height:600px"></div>');
element.append(mainViewport);
const unrelated = document.createElement("div");
+4
View File
@@ -1871,6 +1871,10 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
"widgetQuery": {
"loading": "Loading widget data",
"stale": "Could not refresh {widget}; showing saved data"
},
"action": {
"add": "Add",
"apply": "Apply",
@@ -6,6 +6,7 @@ import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelAlerts",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
@@ -33,6 +33,16 @@
overflow: hidden;
}
.grid[data-short] .statTile {
flex-direction: row;
gap: 3px;
padding: 3px 4px;
}
.grid[data-short] .statLabel {
text-align: left;
}
.statTileWarning {
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
}
+7 -2
View File
@@ -26,6 +26,7 @@ const statIcons = {
} as const;
const gridColsByWidth = [
{ minWidth: 640, cols: 4 },
{ minWidth: 380, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
@@ -74,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width, height }:
return (
<div className={classes.root}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
<div
className={classes.grid}
data-short={height < 120 || undefined}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const value = statValues[statKey];
@@ -99,7 +104,7 @@ export function getGridCols(width: number, height: number, itemCount: number): n
if (itemCount <= 1) return 1;
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
const maxColumnsByWidth = Math.max(1, Math.floor(width / 100));
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
+2 -2
View File
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(1);
expect(getGridCols(220, 100, 4)).toBe(2);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4)).toBe(2);
expect(getGridCols(800, 400, 4)).toBe(4);
});
test("sizes icons from the constrained dimension", () => {
@@ -50,7 +50,6 @@ export default function BeszelAlertsWidget({
integrationIds,
isEditMode,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -85,9 +84,9 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && height >= 360;
const showAlertDescriptions = height >= 190;
if (isPending) {
return (
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
@@ -21,6 +21,10 @@ describe("localized widget formatting", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("keeps date-only values on the requested calendar day", () => {
expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
+10 -2
View File
@@ -1,5 +1,7 @@
export type LocalizedDateValue = Date | number | string;
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
@@ -7,7 +9,7 @@ interface LocalizedTimeOptions {
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date = value instanceof Date ? value : new Date(value);
const date = value instanceof Date ? value : new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
@@ -18,7 +20,13 @@ export const formatLocalizedDate = (
fallback = "?",
): string => {
const date = toValidDate(value);
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
if (!date) return fallback;
const normalizedOptions =
typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined
? { ...options, timeZone: "UTC" }
: options;
return new Intl.DateTimeFormat(locale, normalizedOptions).format(date);
};
export const formatLocalizedTime = (
@@ -5,10 +5,14 @@ import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
useScopedI18n: () => (key: string, values?: { widget?: string }) => {
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
if (key === "loading") return "Loading widget data";
return key;
},
}));
const matchMedia = (query: string) => ({
@@ -50,7 +54,19 @@ describe("WidgetQueryErrorIndicator", () => {
),
);
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState))));
expect(host.querySelector("output")?.textContent).toContain("Loading widget data");
});
});
@@ -1,8 +1,10 @@
"use client";
import { Box, Center, Loader, Tooltip } from "@mantine/core";
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
@@ -10,29 +12,34 @@ interface WidgetQueryErrorIndicatorProps {
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useScopedI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = label;
const accessibleLabel = t("stale", { widget: label });
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<Box
<ActionIcon
component="span"
c="orange"
role="img"
color="orange"
variant="subtle"
size="sm"
role="note"
aria-label={accessibleLabel}
tabIndex={0}
style={{ display: "inline-flex", cursor: "help" }}
style={{ cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</Box>
</ActionIcon>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
const t = useScopedI18n("common.widgetQuery");
return (
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
<Loader size="sm" />
<VisuallyHidden>{t("loading")}</VisuallyHidden>
</Center>
);
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
@@ -29,11 +29,3 @@ describe("isInitialWidgetQueryPending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
describe("hasStaleWidgetQueryError", () => {
test("only marks failures that retain usable cached data", () => {
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
});
});
@@ -16,7 +16,3 @@ export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query:
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
error !== null && error !== undefined && data !== undefined;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component";
import { formatWeatherDate } from "./icon";
describe("weather formatting", () => {
it("preserves valid zero temperatures", () => {
@@ -19,4 +20,17 @@ describe("weather formatting", () => {
expect(getVisibleForecastDays(7, 120, 100)).toBe(2);
expect(getVisibleForecastDays(7, 600, 100)).toBe(3);
});
it.each([
["dddd, MMMM D", "Monday, August 3"],
["dddd, D MMMM", "Monday, 3 August"],
["MMM D", "Aug 3"],
["D MMM", "3 Aug"],
["DD/MM/YYYY", "03/08/2026"],
["MM/DD/YYYY", "08/03/2026"],
["DD/MM", "03/08"],
["MM/DD", "08/03"],
] as const)("honors the saved %s date format", (pattern, expected) => {
expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected);
});
});
+34 -9
View File
@@ -19,7 +19,6 @@ import type { TranslationObject } from "@homarr/translation";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { formatLocalizedDate } from "../common/locale";
import type { WidgetProps } from "../definition";
interface WeatherIconProps {
@@ -130,16 +129,42 @@ export const formatWeatherDate = (
pattern: WidgetProps<"weather">["options"]["dateFormat"] | undefined,
) => {
if (!value) return "?";
const resolvedPattern = pattern ?? "dddd, MMMM D";
const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
if (Number.isNaN(date.getTime())) return "?";
const options: Intl.DateTimeFormatOptions = pattern?.includes("dddd")
? { weekday: "long", month: "long", day: "numeric" }
: pattern?.includes("YYYY")
? { year: "numeric", month: "2-digit", day: "2-digit" }
: pattern?.includes("MMM")
? { month: "short", day: "numeric" }
: { month: "2-digit", day: "2-digit" };
const parts = new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: resolvedPattern.includes("MMMM") ? "long" : resolvedPattern.includes("MMM") ? "short" : "2-digit",
day: resolvedPattern.includes("DD") ? "2-digit" : "numeric",
timeZone: "UTC",
})
.formatToParts(date)
.reduce<Record<string, string>>((result, part) => {
if (part.type !== "literal") result[part.type] = part.value;
return result;
}, {});
return formatLocalizedDate(value, locale, options);
switch (resolvedPattern) {
case "dddd, D MMMM":
return `${parts.weekday}, ${parts.day} ${parts.month}`;
case "MMM D":
return `${parts.month} ${parts.day}`;
case "D MMM":
return `${parts.day} ${parts.month}`;
case "DD/MM/YYYY":
return `${parts.day}/${parts.month}/${parts.year}`;
case "MM/DD/YYYY":
return `${parts.month}/${parts.day}/${parts.year}`;
case "DD/MM":
return `${parts.day}/${parts.month}`;
case "MM/DD":
return `${parts.month}/${parts.day}`;
case "dddd, MMMM D":
default:
return `${parts.weekday}, ${parts.month} ${parts.day}`;
}
};
interface WeatherDefinitionType {