From 6f1c81bc04a3b56b5263b93c9e25b7f63a2d9978 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:58:25 +0200 Subject: [PATCH] fix: smooth advanced focus entrance --- .../advanced-focus/advanced-focus.module.css | 6 ++ .../board/advanced-focus/entrance.spec.ts | 62 ++++++++++++++++ .../board/advanced-focus/entrance.ts | 13 ++++ .../components/board/items/item-content.tsx | 74 ++++++++++++++----- 4 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/entrance.ts diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css index 1293638ba..c1e05e899 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -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; diff --git a/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts b/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts new file mode 100644 index 000000000..fb952e07e --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/entrance.ts b/apps/nextjs/src/components/board/advanced-focus/entrance.ts new file mode 100644 index 000000000..998d01248 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/entrance.ts @@ -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); + }); +}; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 88aa3dfb6..614f9017e 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -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(null); const cardRef = useRef(null); + const previewEntranceFrameRef = useRef(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)} > -
mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}> +
{!supportsAdvancedFocus && widgetCard}
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)} {isPreview && ( -
mountPortalTarget(node, "preview")} - className={advancedFocusClasses.surfaceHostAdvanced} - /> +
)} {isManual && activeFocus && advancedRect && closePosition && ( @@ -357,10 +394,7 @@ const LoadedBoardItemContent = ({ contentRef={setManualSurface} onClose={() => close()} > -
mountPortalTarget(node, "manual")} - className={advancedFocusClasses.manualSurfaceMount} - /> +
)} @@ -400,7 +434,7 @@ interface InnerContentProps { Component: ComponentType>; } -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 )} ); -}; +}); const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error;