fix: refine advanced widget access

This commit is contained in:
ajnart committed 2026-08-14 11:07:09 +02:00
1 parent bd4bbcaca2
commit 464f30799a
7 files changed
+78 -45

No files matched your search

+5 -5
View File
@@ -43,12 +43,12 @@ 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, 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
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
- **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; open tooltips close when switching between compact and advanced mode
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
@@ -89,13 +89,13 @@ import contextMenuImg from "./img/context-menu.png";
### Interactions
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
- **Sort** the current view by clicking column headers to switch direction
<WidgetInteractionGuide />
+1 -1
View File
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
{
name: "Hide completed torrent under this threshold (in kiB/s)",
description:
"This will hide completed torrent entries that have a download speed below the specified threshold.",
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
defaultValue: "0",
},
@@ -3,7 +3,7 @@
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -14,6 +14,7 @@ import type { FocusRect } from "./geometry";
interface ActiveFocus {
itemId: string;
source: HTMLElement;
restoreFocusTarget: HTMLElement;
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
@@ -24,10 +25,12 @@ interface ActiveFocus {
interface OpenAdvancedFocusOptions {
activation?: ActiveFocus["activation"];
autofocusClose?: boolean;
restoreFocusTarget?: HTMLElement;
}
interface AdvancedFocusContextValue {
active: ActiveFocus | null;
viewportSize: { width: number; height: number };
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
@@ -46,6 +49,7 @@ const isEditableTarget = (target: EventTarget | null) =>
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const viewportSize = useViewportSize();
const [active, setActive] = useState<ActiveFocus | null>(null);
const [isHoldPending, setIsHoldPending] = useState(false);
const activeRef = useRef<ActiveFocus | null>(null);
@@ -92,6 +96,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
updateActive({
itemId,
source,
restoreFocusTarget: options.restoreFocusTarget ?? source,
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
@@ -113,7 +118,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
updateActive(null);
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
if (restoreFocus && current.restoreFocusTarget.isConnected)
requestAnimationFrame(() => current.restoreFocusTarget.focus());
}, closeDuration);
},
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
@@ -210,7 +216,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return;
}
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
if (underlyingSource.hasAttribute("data-advanced-focus-enabled")) {
hover(itemId, underlyingSource);
return;
}
@@ -310,8 +316,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}, [active?.activation]);
const value = useMemo(
() => ({ active, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open],
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
);
return (
@@ -24,3 +24,18 @@
pointer-events: none !important;
user-select: none !important;
}
.advancedFocusTrigger {
position: absolute;
top: var(--mantine-spacing-xs);
left: 50%;
z-index: 2;
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
}
.advancedFocusTrigger:focus-visible {
opacity: 1;
pointer-events: auto;
}
@@ -1,10 +1,10 @@
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import type { CardProps } from "@mantine/core";
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -153,15 +153,16 @@ const LoadedBoardItemContent = ({
}: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const sourceRef = useRef<HTMLDivElement>(null);
const advancedFocusTriggerRef = useRef<HTMLButtonElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const [isEditMode] = useEditMode();
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
@@ -185,17 +186,13 @@ const LoadedBoardItemContent = ({
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
event.currentTarget !== event.target ||
event.key !== "Enter" ||
!event.shiftKey ||
isEditMode ||
!supportsAdvancedFocus
)
return;
event.preventDefault();
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
const openAdvancedView = () => {
if (sourceRef.current)
open(item.id, sourceRef.current, {
activation: "manual",
autofocusClose: true,
restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current,
});
};
useEffect(() => {
@@ -353,16 +350,10 @@ const LoadedBoardItemContent = ({
w="100%"
h="100%"
data-advanced-focus-source
data-advanced-focus-enabled={supportsAdvancedFocus || undefined}
data-item-id={item.id}
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
aria-label={
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
}
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
aria-label={!isEditMode && supportsAdvancedFocus ? widgetName : undefined}
onPointerEnter={() => {
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
}}
@@ -371,6 +362,21 @@ const LoadedBoardItemContent = ({
}}
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
{!isEditMode && supportsAdvancedFocus && (
<Button
ref={advancedFocusTriggerRef}
type="button"
size="compact-xs"
className={itemContentClasses.advancedFocusTrigger}
aria-expanded={isAdvanced}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts="Shift+Enter"
data-advanced-focus-trigger
onClick={openAdvancedView}
>
{t("item.advancedFocus.open")}
</Button>
)}
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
{!supportsAdvancedFocus && widgetCard}
</div>
@@ -227,7 +227,12 @@ export const WidgetContextMenu = ({
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
+10 -9
View File
@@ -68,6 +68,7 @@ describe("Board advanced interactions", () => {
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
const advancedViewTrigger = widget.getByRole("button", { name: "Open advanced view" });
const compactSurface = page.locator(".clock-wrapper").first();
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
@@ -83,7 +84,7 @@ describe("Board advanced interactions", () => {
element.setAttribute("data-lifecycle-probe", "same-instance");
});
await widget.hover();
await widget.focus();
await advancedViewTrigger.focus();
await page.keyboard.down("Shift");
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
await expect(previewSurface).toHaveCSS("animation-name", "none");
@@ -95,7 +96,7 @@ describe("Board advanced interactions", () => {
await expect(dimmingOverlay).toBeVisible();
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
await expect(otherWidget).toBeVisible();
await expect(widget).toBeFocused();
await expect(advancedViewTrigger).toBeFocused();
await expect(header).not.toHaveAttribute("inert", "");
await expect(boardMain).not.toHaveAttribute("inert", "");
@@ -140,7 +141,7 @@ describe("Board advanced interactions", () => {
await expect(bookmarksPreviewSurface).toBeHidden();
await expect(dimmingOverlay).toBeHidden();
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(widget).toBeFocused();
await expect(advancedViewTrigger).toBeFocused();
await page.keyboard.press("Shift+Enter");
await expect(manualSurface).toBeVisible();
@@ -220,7 +221,7 @@ describe("Board advanced interactions", () => {
await expect(manualSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(manualSurface).toBeHidden();
await expect(widget).toBeFocused();
await expect(advancedViewTrigger).toBeFocused();
await expect(header).not.toHaveAttribute("inert", "");
await expect(boardMain).not.toHaveAttribute("inert", "");
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
@@ -232,13 +233,13 @@ describe("Board advanced interactions", () => {
if (!viewport) throw new Error("Board page has no viewport");
await page.mouse.click(4, viewport.height - 4);
await expect(manualSurface).toBeHidden();
await expect(widget).toBeFocused();
await expect(advancedViewTrigger).toBeFocused();
for (const compactOnlyKind of ["app", "iframe"]) {
const compactOnlyWidget = page
.locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`)
.first();
await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await expect(compactOnlyWidget.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
@@ -266,7 +267,7 @@ describe("Board advanced interactions", () => {
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
await frame.locator("#state").fill("changed");
await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await expect(iframeSlot.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
await iframeSlot.dispatchEvent("contextmenu", { button: 2 });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
@@ -279,7 +280,7 @@ describe("Board advanced interactions", () => {
const bookmarksSlot = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
await bookmarksSlot.focus();
await bookmarksSlot.getByRole("button", { name: "Open advanced view" }).focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
await page.setViewportSize({ width: 800, height: 900 });
@@ -295,7 +296,7 @@ describe("Board advanced interactions", () => {
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.focus();
await widget.getByRole("button", { name: "Open advanced view" }).focus();
await page.keyboard.press("Shift+Enter");
const closeButton = page.getByRole("button", { name: "Close advanced view" });
const bounds = await closeButton.boundingBox();