fix: refine advanced widget access
This commit is contained in:
1 parent
bd4bbcaca2
commit
464f30799a
7 files changed
+78
-45
No files matched your search
@@ -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 />
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user