fix: smooth advanced focus entrance

This commit is contained in:
Thomas Camlong committed 2026-08-09 18:06:26 +02:00
1 parent 00688d5c1a
commit 6f1c81bc04
4 files changed
+135 -20

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;
@@ -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;