Merge custom option test fix into mobile boards
This commit is contained in:
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}>⇧</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) => {
|
||||
|
||||
Reference in new issue
Block a user