From 464f30799aa6e032c6a6541392e67cf04561bc60 Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 14 Aug 2026 10:16:26 +0200 Subject: [PATCH] fix: refine advanced widget access --- apps/docs/docs/widgets/downloads/index.mdx | 10 ++-- apps/docs/docs/widgets/downloads/index.ts | 2 +- .../board/advanced-focus/context.tsx | 16 ++++-- .../board/items/item-content.module.css | 15 ++++++ .../components/board/items/item-content.tsx | 54 ++++++++++--------- .../board/items/widget-context-menu.tsx | 7 ++- e2e/board-advanced-interactions.spec.ts | 19 +++---- 7 files changed, 78 insertions(+), 45 deletions(-) diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 47e7a901f..425efb0a7 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -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 diff --git a/apps/docs/docs/widgets/downloads/index.ts b/apps/docs/docs/widgets/downloads/index.ts index 4245af310..fba87aad1 100644 --- a/apps/docs/docs/widgets/downloads/index.ts +++ b/apps/docs/docs/widgets/downloads/index.ts @@ -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", }, diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 00fffb084..bc2028961 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -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(null); const [isHoldPending, setIsHoldPending] = useState(false); const activeRef = useRef(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 ( diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index bead20b70..7100f3197 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -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; +} diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 614f9017e..e9b041df2 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -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(null); + const advancedFocusTriggerRef = useRef(null); const cardRef = useRef(null); const previewEntranceFrameRef = useRef(null); - const { width: viewportWidth, height: viewportHeight } = useViewportSize(); const board = useRequiredBoard(); const t = useI18n(); const [isEditMode] = useEditMode(); const [manualSurface, setManualSurface] = useState(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState(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) => { - 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 && ( + + )}
{!supportsAdvancedFocus && widgetCard}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 50d69da51..af245192c 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -227,7 +227,12 @@ export const WidgetContextMenu = ({ closeMenuOnClick leftSection={} onClick={() => { - if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current); + if (sourceRef.current) + openAdvancedFocus(item.id, sourceRef.current, { + restoreFocusTarget: + sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? + sourceRef.current, + }); }} > {t("item.advancedFocus.open")} diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index b970eec28..693de4c67 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -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();