diff --git a/plugins/inline-vis/app.test.tsx b/plugins/inline-vis/app.test.tsx index 9dd40b01bc..567907ee86 100644 --- a/plugins/inline-vis/app.test.tsx +++ b/plugins/inline-vis/app.test.tsx @@ -63,7 +63,9 @@ describe("InlineVisDirective", () => { }, ); - await slot.findByText(/Loading visualization charts\/demo file\.html/i); + await slot.findByRole("status", { + name: "Loading visualization charts/demo file.html", + }); const iframe = await waitFor(() => { const el = slot.container.querySelector("iframe"); @@ -116,6 +118,103 @@ describe("InlineVisDirective", () => { expect(iframe.style.height).toBe("480px"); }); + it("renders a complete fixed skeleton with shimmer-only motion", async () => { + let resolvePreview = (_result: { file: string }) => {}; + const pendingPreview = new Promise<{ file: string }>((resolve) => { + resolvePreview = resolve; + }); + const openWorkspaceFile = vi.fn(() => true); + const slot = renderSlot( + app.messageDirectives[0]!, + { + attributes: { file: "demo.html", height: "480" }, + source: '::inline-vis{file="demo.html" height="480"}', + message, + openWorkspaceFile, + }, + { + rpc: { + prepareHtmlPreview: () => pendingPreview, + }, + }, + ); + + const loadingSkeleton = await slot.findByRole("status", { + name: "Loading visualization demo.html", + }); + expect(loadingSkeleton.style.height).toBe("480px"); + const skeletonSteps = Array.from( + loadingSkeleton.querySelectorAll("[data-inline-vis-skeleton-step]"), + ); + expect( + skeletonSteps.map((step) => + step.getAttribute("data-inline-vis-skeleton-step"), + ), + ).toEqual([ + "region-1", + "region-2", + "region-3", + "region-4", + "region-5", + "region-6", + ]); + for (const step of skeletonSteps) { + expect( + Array.from(step.classList).some((className) => + className.startsWith("delay-"), + ), + ).toBe(false); + expect(step.classList.contains("animate-in")).toBe(false); + expect(step.classList.contains("fade-in-0")).toBe(false); + expect(step.classList.contains("slide-in-from-bottom-1")).toBe(false); + expect(step.classList.contains("fill-mode-backwards")).toBe(false); + } + const shimmerElements = Array.from( + loadingSkeleton.querySelectorAll("[data-inline-vis-skeleton-shimmer]"), + ); + expect(shimmerElements).toHaveLength(6); + for (const shimmerElement of shimmerElements) { + expect(shimmerElement.classList.contains("animate-shine-icon")).toBe( + true, + ); + } + expect(loadingSkeleton.querySelectorAll(".animate-pulse")).toHaveLength(0); + expect( + loadingSkeleton.querySelector("[data-inline-vis-skeleton-canvas]"), + ).not.toBeNull(); + expect( + loadingSkeleton.querySelector("[data-inline-vis-skeleton-plot]"), + ).toBeNull(); + expect( + skeletonSteps.some((step) => + step.getAttribute("data-inline-vis-skeleton-step")?.includes("bar"), + ), + ).toBe(false); + expect( + loadingSkeleton.querySelector(".min-h-0.w-full.flex-1.animate-pulse"), + ).toBeNull(); + const loadingCard = loadingSkeleton.parentElement; + const actionSpacer = loadingCard?.firstElementChild?.lastElementChild; + expect(actionSpacer?.getAttribute("aria-hidden")).toBe("true"); + expect(actionSpacer?.classList.contains("size-5")).toBe(true); + + resolvePreview({ file: "demo.html" }); + + const iframe = await waitFor(() => { + const el = slot.container.querySelector("iframe"); + if (!(el instanceof HTMLIFrameElement)) { + throw new Error("Expected the inline visualization iframe to render"); + } + return el; + }); + expect(iframe.style.height).toBe("480px"); + expect( + slot.queryByRole("status", { + name: "Loading visualization demo.html", + }), + ).toBeNull(); + }); + it("rejects an invalid height without calling rpc", async () => { const slot = renderSlot( app.messageDirectives[0]!, diff --git a/plugins/inline-vis/app.tsx b/plugins/inline-vis/app.tsx index 750773970c..7cb3de1642 100644 --- a/plugins/inline-vis/app.tsx +++ b/plugins/inline-vis/app.tsx @@ -4,6 +4,7 @@ // resources work inside an opaque origin that cannot access the host page. import { useEffect, useState } from "react"; import { Icon } from "@bb/shared-ui/icon"; +import { Skeleton } from "@bb/shared-ui/skeleton"; import { definePluginApp, useRpc, @@ -21,6 +22,14 @@ type LoadState = const DEFAULT_HEIGHT_PX = 224; const MIN_HEIGHT_PX = 120; const MAX_HEIGHT_PX = 1_200; +const SKELETON_REGIONS = [ + { id: "region-1", layoutClass: "col-span-7 row-span-2" }, + { id: "region-2", layoutClass: "col-span-5" }, + { id: "region-3", layoutClass: "col-span-5" }, + { id: "region-4", layoutClass: "col-span-4 row-span-2" }, + { id: "region-5", layoutClass: "col-span-5 row-span-2" }, + { id: "region-6", layoutClass: "col-span-3 row-span-2" }, +] as const; interface PrepareHtmlPreviewRpcResult { file: string; @@ -153,10 +162,46 @@ function InlineVisDirective({ if (state.status === "loading") { return (