Merge custom option test fix into mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-09 18:08:39 +02:00
commit 2c97eec1d0
32 files changed
+798 -238

No files matched your search

@@ -16,9 +16,15 @@
box-shadow: var(--mantine-shadow-md);
overscroll-behavior: contain;
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
animation-fill-mode: backwards;
animation-play-state: paused;
transform-origin: top left;
}
.surfaceReady {
animation-play-state: running;
}
.manualSurface {
position: fixed;
max-width: none;
@@ -82,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;
@@ -110,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) {
@@ -123,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,18 +161,38 @@ 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)
@@ -151,7 +205,10 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
)
.find((source) => source !== null && source !== current.source);
const itemId = underlyingSource?.dataset.itemId;
if (!underlyingSource || !itemId) return;
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
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from "vitest";
import { startAdvancedFocusEntrance } from "./entrance";
describe("startAdvancedFocusEntrance", () => {
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
});
test("moves the persistent portal before releasing its source-frame animation", () => {
let releaseFrame: FrameRequestCallback | undefined;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
releaseFrame = callback;
return 7;
}),
);
const compactHost = document.createElement("div");
const previewHost = document.createElement("div");
const portalTarget = document.createElement("div");
const surface = document.createElement("div");
surface.classList.add("surface-ready");
portalTarget.append(surface);
compactHost.append(portalTarget);
document.body.append(compactHost, previewHost);
expect(startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready")).toBe(7);
expect(portalTarget.parentElement).toBe(previewHost);
expect(surface.classList.contains("surface-ready")).toBe(false);
releaseFrame?.(0);
expect(surface.classList.contains("surface-ready")).toBe(true);
});
test("does not release a detached surface", () => {
let releaseFrame: FrameRequestCallback | undefined;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
releaseFrame = callback;
return 8;
}),
);
const previewHost = document.createElement("div");
const portalTarget = document.createElement("div");
const surface = document.createElement("div");
portalTarget.append(surface);
document.body.append(previewHost);
startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready");
portalTarget.remove();
releaseFrame?.(0);
expect(surface.classList.contains("surface-ready")).toBe(false);
});
});
@@ -0,0 +1,13 @@
export const startAdvancedFocusEntrance = (
portalTarget: HTMLElement,
host: HTMLElement,
surface: HTMLElement,
readyClass: string,
) => {
host.append(portalTarget);
surface.classList.remove(readyClass);
return requestAnimationFrame(() => {
if (surface.isConnected) surface.classList.add(readyClass);
});
};
@@ -1,5 +1,5 @@
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import { Suspense, use, useEffect, useRef, useState } 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";
@@ -27,6 +27,7 @@ import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
@@ -153,6 +154,7 @@ const LoadedBoardItemContent = ({
const { definition, Component } = use(loadWidgetResources(item.kind));
const sourceRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
@@ -213,14 +215,52 @@ const LoadedBoardItemContent = ({
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
}, [isAdvanced, isManual, manualSurface]);
const mountPortalTarget = (node: HTMLDivElement | null, activation: "compact" | "preview" | "manual") => {
if (!node || !surfacePortalTarget) return;
const shouldMount =
(activation === "compact" && !isAdvanced) ||
(activation === "preview" && isPreview) ||
(activation === "manual" && isManual);
if (shouldMount) node.append(surfacePortalTarget);
};
const cancelPreviewEntrance = useCallback(() => {
if (previewEntranceFrameRef.current === null) return;
cancelAnimationFrame(previewEntranceFrameRef.current);
previewEntranceFrameRef.current = null;
}, []);
const mountCompactHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || isAdvanced) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
);
const mountPreviewHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isPreview) {
if (!host) cancelPreviewEntrance();
return;
}
if (activeFocus?.phase !== "visible") {
host.append(surfacePortalTarget);
return;
}
const card = cardRef.current;
const readyClass = advancedFocusClasses.surfaceReady;
if (!card || !readyClass) return;
cancelPreviewEntrance();
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
},
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
);
const mountManualHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isManual) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isManual, surfacePortalTarget],
);
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
const previewStyle =
isPreview && advancedRect && activeFocus
@@ -331,16 +371,13 @@ const LoadedBoardItemContent = ({
}}
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
{!supportsAdvancedFocus && widgetCard}
</div>
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
{isPreview && (
<Portal reuseTargetNode={false}>
<div
ref={(node) => mountPortalTarget(node, "preview")}
className={advancedFocusClasses.surfaceHostAdvanced}
/>
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
</Portal>
)}
{isManual && activeFocus && advancedRect && closePosition && (
@@ -357,10 +394,7 @@ const LoadedBoardItemContent = ({
contentRef={setManualSurface}
onClose={() => close()}
>
<div
ref={(node) => mountPortalTarget(node, "manual")}
className={advancedFocusClasses.manualSurfaceMount}
/>
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
</AdvancedFocusManualSurface>
)}
</Box>
@@ -400,7 +434,7 @@ interface InnerContentProps {
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
}
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -474,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
)}
</QueryErrorResetBoundary>
);
};
});
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
@@ -5,12 +5,14 @@ The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
- the canvas is transformed once with one uniform scale derived from its available width.
- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
stable.
stable. The visual canvas always fits the available width, every root lane
keeps at least one viewport of height, and edit-mode previews extend it downward
as needed.
The read-only renderer should:
@@ -5,6 +5,5 @@
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
};
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
columnCount: number,
@@ -2,9 +2,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
overflow-x: auto;
overscroll-behavior-inline: contain;
overflow: clip;
}
.viewport[data-board-hydrated="false"] {
@@ -18,8 +16,6 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
transform-origin: top left;
will-change: transform;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
@@ -44,9 +40,3 @@
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
}
@media (prefers-reduced-motion: reduce) {
.canvas {
will-change: auto;
}
}
@@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
import classes from "./scaled-board-canvas.module.css";
export { MIN_ACCESSIBLE_CANVAS_SCALE };
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
@@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The scale floor deliberately allows horizontal
* overflow so browser zoom still magnifies content instead of being cancelled
* by responsive downscaling.
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({
[logicalWidth, resolvedAvailableWidth],
);
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
@@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({
className={classes.canvas}
w={logicalWidth}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
transform: `scale(${scale})`,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
import {
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
normalizeGridPlacement,
} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
@@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => {
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
});
test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
});
});
@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
describe("board canvas scale policy", () => {
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
expect(scale).toBe(0.84);
@@ -14,16 +14,16 @@ describe("board canvas scale policy", () => {
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
});
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
test("keeps fitting narrow viewports without horizontal overflow", () => {
const availableWidth = 600;
const logicalWidth = 1000;
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
expect(scale).toBe(0.6);
expect(logicalWidth * scale).toBe(availableWidth);
});
test("keeps content materially magnified at 200% effective zoom", () => {
test("keeps the board fitted at 200% effective zoom", () => {
const logicalWidth = 1696;
const normalAvailableWidth = 1872;
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
@@ -31,8 +31,8 @@ describe("board canvas scale policy", () => {
const normalPaintedCellSize = 200 * normalScale;
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
expect(zoomedScale).toBeCloseTo(normalScale / 2);
expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
});
test.each([
@@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
const [isEditMode] = useEditMode();
const t = useScopedI18n("board.landmark");
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
return (
<SectionGrid
@@ -86,6 +86,10 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
overflow: visible;
}
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
overflow: clip;
}
.board-grid-placeholder {
z-index: 0;
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
@@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
height: 32px;
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ns-resize;
}
@@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
width: 32px;
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ew-resize;
}
@@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
width: 44px;
height: 44px;
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
@@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract";
import { pointerIntersection } from "@dnd-kit/collision";
import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
import { RestrictToElement } from "@dnd-kit/dom/modifiers";
import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
import type {
BeforeDragStartEvent,
@@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
if (!previewGrid || !interaction) return placements;
if (interaction.sourceGridId === sectionId) {
if (interaction.mode === "resize") return previewGrid.placements;
const original = controlledById.get(interaction.activeId);
if (!original) return previewGrid.placements;
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
@@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
const growsDuringDrag =
interaction?.mode === "drag" &&
(interaction.targetGridId === sectionId ||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const participatesInInteraction =
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
section.kind !== "container" && interaction?.mode === "resize"
? Math.max(rowCount, getLayoutRowCount(renderPlacements))
section.kind !== "container" && participatesInInteraction
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
@@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
],
[],
);
const modifiers = useMemo(
() => [
RestrictToElement.configure({
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
}),
],
[],
);
const {
ref: draggableRef,
handleRef,
@@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
type: GRID_ENTRY_TYPE,
data: sourceData,
sensors,
modifiers,
});
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Box, LoadingOverlay } from "@mantine/core";
import combineClasses from "clsx";
@@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types
import {
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -81,6 +83,7 @@ export const SectionGrid = ({
className,
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
@@ -144,8 +147,9 @@ export const SectionGrid = ({
);
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount);
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -199,6 +203,24 @@ export const SectionGrid = ({
);
};
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const [visualHeight, setVisualHeight] = useState(0);
useEffect(() => {
if (!enabled) return;
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
update();
window.addEventListener("resize", update, { passive: true });
window.visualViewport?.addEventListener("resize", update, { passive: true });
return () => {
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, [enabled]);
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
};
const getContainerMinimumSize = (
board: ReturnType<typeof useRequiredBoard>,
layoutId: string,
@@ -27,6 +27,7 @@ describe("board grid import boundary", () => {
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
]);
@@ -4,6 +4,8 @@ import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { modalSizeSelect } from "@homarr/modals";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
@@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
Import from Workshop
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
<WorkshopBrowser
type="customCss"
onUseCss={(css) => {