fix: smooth advanced focus entrance
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user