From c9fe69c5eccb5c10e0069a3248265f1c68eb88b7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:59:22 +0200 Subject: [PATCH] feat: improve advanced preview activation --- .../advanced/keyboard-shortcuts/index.mdx | 22 +- .../advanced-focus/advanced-focus.module.css | 80 +++++++ .../board/advanced-focus/context.spec.tsx | 195 ++++++++++++++++++ .../board/advanced-focus/context.tsx | 139 ++++++++++--- 4 files changed, 398 insertions(+), 38 deletions(-) create mode 100644 apps/nextjs/src/components/board/advanced-focus/context.spec.tsx diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 2896b7a97..66a0f2f35 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,23 +6,21 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ---------------------------------------------- | ---------------------------------------------------------- | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | -| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view | -| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | -| `Esc` | Closes an open advanced view or board menu | -| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell | +| Shortcut | Description | +| ----------------------------------------------- | ------------------------------------------ | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | +| `Esc` | Closes an open advanced view or board menu | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. -Advanced view and precise placement are available outside edit mode. Widgets without a distinct advanced experience -adapt to their normal dimensions instead. Precise placement still requires permission to change the board and the -right-click interaction preference to be enabled. +Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal +dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open. ::: diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css index c1e05e899..87cd59414 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -88,6 +88,58 @@ pointer-events: auto; } +.holdIndicator { + position: fixed; + top: 0; + left: 0; + z-index: var(--mantine-z-index-modal); + display: grid; + width: 36px; + height: 36px; + color: var(--mantine-color-text); + pointer-events: none; + transform: translate3d(var(--advanced-focus-pointer-x), var(--advanced-focus-pointer-y), 0) translate(-50%, -50%); + place-items: center; + will-change: transform; +} + +.holdIndicatorRing { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%)); + transform: rotate(-90deg); +} + +.holdIndicatorTrack, +.holdIndicatorProgress { + stroke-width: 3; +} + +.holdIndicatorTrack { + fill: color-mix(in srgb, var(--mantine-color-body) 82%, transparent); + stroke: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent); +} + +.holdIndicatorProgress { + fill: none; + stroke: var(--mantine-primary-color-filled); + stroke-linecap: round; + stroke-dasharray: 1; + stroke-dashoffset: 1; + animation: advanced-focus-hold-progress 500ms linear forwards; +} + +.holdIndicatorLabel { + position: relative; + font-size: 15px; + font-weight: 700; + line-height: 1; + text-shadow: 0 1px 2px rgb(0 0 0 / 25%); +} + @keyframes focus-backdrop-in { from { opacity: 0; @@ -116,12 +168,23 @@ } } +@keyframes advanced-focus-hold-progress { + to { + stroke-dashoffset: 0; + } +} + @media (prefers-reduced-motion: reduce) { .backdrop, .surface, .manualSurface { animation: none; } + + .holdIndicatorProgress { + stroke-dashoffset: 0; + animation: none; + } } @media (forced-colors: active) { @@ -129,4 +192,21 @@ background: Canvas; opacity: 0.4; } + + .holdIndicatorTrack { + fill: Canvas; + } + + .holdIndicatorTrack { + stroke: GrayText; + } + + .holdIndicatorProgress { + stroke: Highlight; + } + + .holdIndicatorLabel { + color: CanvasText; + text-shadow: none; + } } diff --git a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx new file mode 100644 index 000000000..d3fad372a --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx @@ -0,0 +1,195 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { EditModeProvider } from "@homarr/boards/edit-mode"; + +import { BoardAdvancedFocusProvider, useAdvancedFocus } from "./context"; + +const sourceRect = { + left: 20, + top: 30, + right: 220, + bottom: 150, + width: 200, + height: 120, + x: 20, + y: 30, + toJSON: () => undefined, +}; + +describe("BoardAdvancedFocusProvider preview interaction", () => { + let host: HTMLDivElement; + let root: ReturnType; + let focus: ReturnType; + + const Harness = () => { + focus = useAdvancedFocus(); + return createElement( + "div", + null, + createElement("div", { "data-testid": "source" }), + createElement("div", { "data-advanced-focus-surface": true, "data-testid": "surface" }), + createElement( + "div", + { "data-portal": true }, + createElement("button", { role: "menuitem", "data-testid": "portalled-menu-item" }), + ), + createElement("div", { "data-testid": "outside" }), + ); + }; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: vi.fn(() => []), + }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => + root.render( + createElement( + MantineProvider, + null, + createElement( + EditModeProvider, + null, + createElement(BoardAdvancedFocusProvider, null, createElement(Harness)), + ), + ), + ), + ); + + const source = host.querySelector("[data-testid='source']"); + if (!source) throw new Error("Expected source fixture"); + vi.spyOn(source, "getBoundingClientRect").mockReturnValue(sourceRect); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const startPreview = async () => { + const source = host.querySelector("[data-testid='source']"); + if (!source) throw new Error("Expected source fixture"); + await act(async () => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 48, clientY: 52 })); + focus.hover("widget-1", source); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })); + }); + }; + + test("shows pointer-following hold feedback for the full 500 ms delay", async () => { + await startPreview(); + + const indicator = document.querySelector("[data-advanced-focus-hold-indicator]"); + expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-x")).toBe("48px"); + expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-y")).toBe("52px"); + expect(focus.active).toBeNull(); + + await act(async () => vi.advanceTimersByTime(499)); + expect(focus.active).toBeNull(); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).not.toBeNull(); + + await act(async () => vi.advanceTimersByTime(1)); + expect(focus.active?.itemId).toBe("widget-1"); + expect(focus.active?.activation).toBe("preview"); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + }); + + test("does not show hold feedback when Shift is pressed without a hovered source", async () => { + await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }))); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(500)); + expect(focus.active).toBeNull(); + }); + + test("cancels the hold feedback and activation when Shift is released early", async () => { + await startPreview(); + + await act(async () => vi.advanceTimersByTime(250)); + await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" }))); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(250)); + expect(focus.active).toBeNull(); + }); + + test("cancels the hold feedback and activation when the source is left early", async () => { + await startPreview(); + + await act(async () => vi.advanceTimersByTime(250)); + await act(async () => focus.leave("widget-1")); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(250)); + expect(focus.active).toBeNull(); + }); + + test("keeps the preview open across the source-to-portal gap and closes after leaving both", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + + await act(async () => focus.leave("widget-1")); + await act(async () => vi.advanceTimersByTime(40)); + const surface = host.querySelector("[data-testid='surface']"); + if (!surface) throw new Error("Expected surface fixture"); + await act(async () => + surface.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 300, clientY: 200 })), + ); + await act(async () => vi.advanceTimersByTime(80)); + expect(focus.active?.phase).toBe("visible"); + + const outside = host.querySelector("[data-testid='outside']"); + if (!outside) throw new Error("Expected outside fixture"); + await act(async () => + outside.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 500, clientY: 500 })), + ); + await act(async () => vi.advanceTimersByTime(79)); + expect(focus.active?.phase).toBe("visible"); + await act(async () => vi.advanceTimersByTime(1)); + expect(focus.active?.phase).toBe("closing"); + await act(async () => vi.advanceTimersByTime(180)); + expect(focus.active).toBeNull(); + }); + + test("keeps the preview open while a widget-owned portalled popup is hovered", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + + await act(async () => focus.leave("widget-1")); + await act(async () => vi.advanceTimersByTime(40)); + const menuItem = host.querySelector("[data-testid='portalled-menu-item']"); + if (!menuItem) throw new Error("Expected portalled menu fixture"); + await act(async () => + menuItem.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 320, clientY: 210 })), + ); + await act(async () => vi.advanceTimersByTime(80)); + + expect(focus.active?.phase).toBe("visible"); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 17028fecc..00fffb084 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PropsWithChildren } from "react"; +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"; @@ -36,10 +36,9 @@ interface AdvancedFocusContextValue { } const AdvancedFocusContext = createContext(null); -const HOVER_DELAY_MS = 150; +const HOVER_DELAY_MS = 500; +const POINTER_TRANSITION_GRACE_MS = 80; 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 && @@ -48,16 +47,28 @@ const isEditableTarget = (target: EventTarget | null) => export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { const [isEditMode] = useEditMode(); const [active, setActive] = useState(null); + const [isHoldPending, setIsHoldPending] = useState(false); const activeRef = useRef(null); const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null); const shiftHeldRef = useRef(false); const hoverTimerRef = useRef | null>(null); + const previewLeaveTimerRef = useRef | null>(null); const closeTimerRef = useRef | null>(null); + const holdIndicatorRef = useRef(null); + const pointerPositionRef = useRef({ x: -100, y: -100 }); + const pointerFrameRef = useRef(null); const cancelHoverTimer = useCallback(() => { if (hoverTimerRef.current === null) return; clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; + setIsHoldPending(false); + }, []); + + const cancelPreviewLeaveTimer = useCallback(() => { + if (previewLeaveTimerRef.current === null) return; + clearTimeout(previewLeaveTimerRef.current); + previewLeaveTimerRef.current = null; }, []); const updateActive = useCallback((next: ActiveFocus | null) => { @@ -75,6 +86,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { (itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => { if (isEditMode) return; cancelHoverTimer(); + cancelPreviewLeaveTimer(); cancelCloseTimer(); const rect = source.getBoundingClientRect(); updateActive({ @@ -87,12 +99,13 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { phase: "visible", }); }, - [cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive], + [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, isEditMode, updateActive], ); const close = useCallback( (restoreFocus = true) => { cancelHoverTimer(); + cancelPreviewLeaveTimer(); const current = activeRef.current; if (!current || current.phase === "closing") return; updateActive({ ...current, phase: "closing" }); @@ -103,22 +116,43 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus()); }, closeDuration); }, - [cancelHoverTimer, updateActive], + [cancelHoverTimer, cancelPreviewLeaveTimer, updateActive], + ); + + const schedulePreviewClose = useCallback( + (itemId: string) => { + if (previewLeaveTimerRef.current !== null) return; + previewLeaveTimerRef.current = setTimeout(() => { + previewLeaveTimerRef.current = null; + const current = activeRef.current; + if (current?.activation !== "preview" || current.itemId !== itemId) return; + if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; + close(current.restorePreviewFocus); + }, POINTER_TRANSITION_GRACE_MS); + }, + [close], ); const startHoverTimer = useCallback(() => { cancelHoverTimer(); const hovered = hoveredRef.current; if (!hovered || !shiftHeldRef.current || activeRef.current) return; - hoverTimerRef.current = setTimeout( - () => open(hovered.itemId, hovered.source, { activation: "preview" }), - HOVER_DELAY_MS, - ); + if (pointerPositionRef.current.x < 0 || pointerPositionRef.current.y < 0) { + const rect = hovered.source.getBoundingClientRect(); + pointerPositionRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + setIsHoldPending(true); + hoverTimerRef.current = setTimeout(() => { + hoverTimerRef.current = null; + setIsHoldPending(false); + open(hovered.itemId, hovered.source, { activation: "preview" }); + }, HOVER_DELAY_MS); }, [cancelHoverTimer, open]); const hover = useCallback( (itemId: string, source: HTMLElement) => { hoveredRef.current = { itemId, source }; + cancelPreviewLeaveTimer(); const current = activeRef.current; if (current?.activation === "preview" && current.itemId !== itemId) { cancelCloseTimer(); @@ -127,31 +161,54 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { } startHoverTimer(); }, - [cancelCloseTimer, startHoverTimer, updateActive], + [cancelCloseTimer, cancelPreviewLeaveTimer, startHoverTimer, updateActive], ); useEffect(() => { const handlePointerMove = (event: PointerEvent) => { + pointerPositionRef.current = { x: event.clientX, y: event.clientY }; + if (holdIndicatorRef.current && pointerFrameRef.current === null) { + pointerFrameRef.current = requestAnimationFrame(() => { + pointerFrameRef.current = null; + const indicator = holdIndicatorRef.current; + if (!indicator) return; + indicator.style.setProperty("--advanced-focus-pointer-x", `${pointerPositionRef.current.x}px`); + indicator.style.setProperty("--advanced-focus-pointer-y", `${pointerPositionRef.current.y}px`); + }); + } + const current = activeRef.current; if (!shiftHeldRef.current || current?.activation !== "preview") return; - if ( - event.target instanceof Element && - event.target.closest(interactiveSelector)?.closest("[data-advanced-focus-surface]") - ) + if (event.target instanceof Element && event.target.closest("[data-advanced-focus-surface], [data-portal]")) { + cancelPreviewLeaveTimer(); return; + } + + const isInsideSourceRect = + event.clientX >= current.sourceRect.left && + event.clientX <= current.sourceRect.left + current.sourceRect.width && + event.clientY >= current.sourceRect.top && + event.clientY <= current.sourceRect.top + current.sourceRect.height; + if (isInsideSourceRect) { + cancelPreviewLeaveTimer(); + return; + } const underlyingSource = document .elementsFromPoint(event.clientX, event.clientY) .map((element) => element instanceof HTMLElement - ? element.matches(".grid-stack-item-content") + ? element.matches("[data-advanced-focus-source]") ? element - : element.closest(".grid-stack-item-content") + : element.closest("[data-advanced-focus-source]") : null, ) .find((source) => source !== null && source !== current.source); - const itemId = underlyingSource?.closest(".grid-stack-item[data-id]")?.dataset.id; - if (!underlyingSource || !itemId) return; + const itemId = underlyingSource?.dataset.itemId; + if (!underlyingSource || !itemId) { + schedulePreviewClose(current.itemId); + return; + } if (underlyingSource.hasAttribute("aria-keyshortcuts")) { hover(itemId, underlyingSource); @@ -160,31 +217,38 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { hoveredRef.current = null; cancelHoverTimer(); - cancelCloseTimer(); - updateActive(null); + schedulePreviewClose(current.itemId); }; window.addEventListener("pointermove", handlePointerMove, { capture: true }); - return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true }); - }, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]); + window.addEventListener("pointerover", handlePointerMove, { capture: true }); + return () => { + window.removeEventListener("pointermove", handlePointerMove, { capture: true }); + window.removeEventListener("pointerover", handlePointerMove, { capture: true }); + if (pointerFrameRef.current !== null) cancelAnimationFrame(pointerFrameRef.current); + }; + }, [cancelHoverTimer, cancelPreviewLeaveTimer, hover, schedulePreviewClose]); const leave = useCallback( (itemId: string) => { if (hoveredRef.current?.itemId !== itemId) return; hoveredRef.current = null; cancelHoverTimer(); + if (activeRef.current?.activation === "preview" && activeRef.current.itemId === itemId) + schedulePreviewClose(itemId); }, - [cancelHoverTimer], + [cancelHoverTimer, schedulePreviewClose], ); const dismiss = useCallback( (itemId: string) => { if (activeRef.current?.itemId !== itemId) return; cancelHoverTimer(); + cancelPreviewLeaveTimer(); cancelCloseTimer(); updateActive(null); }, - [cancelCloseTimer, cancelHoverTimer, updateActive], + [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive], ); useEffect(() => { @@ -223,9 +287,10 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); cancelHoverTimer(); + cancelPreviewLeaveTimer(); cancelCloseTimer(); }; - }, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]); + }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]); useEffect(() => { if (isEditMode && activeRef.current) close(false); @@ -252,6 +317,28 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { return ( {children} + {isHoldPending && ( + +
+ + + + + ⇧ +
+
+ )} {active?.activation === "preview" && (