feat: improve advanced preview activation

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:53 +02:00
1 parent 64a57bac82
commit c9fe69c5ec
4 files changed
+398 -38

No files matched your search

@@ -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.
:::
@@ -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;
}
}
@@ -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<typeof createRoot>;
let focus: ReturnType<typeof useAdvancedFocus>;
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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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");
});
});
@@ -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<AdvancedFocusContextValue | null>(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<ActiveFocus | null>(null);
const [isHoldPending, setIsHoldPending] = useState(false);
const activeRef = useRef<ActiveFocus | null>(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
const pointerPositionRef = useRef({ x: -100, y: -100 });
const pointerFrameRef = useRef<number | null>(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<HTMLElement>(".grid-stack-item-content")
: element.closest<HTMLElement>("[data-advanced-focus-source]")
: null,
)
.find((source) => source !== null && source !== current.source);
const itemId = underlyingSource?.closest<HTMLElement>(".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 (
<AdvancedFocusContext.Provider value={value}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay