From 1653cdaa940be85cedab4f9f83463356d72e5306 Mon Sep 17 00:00:00 2001 From: "Md. Saikat Islam" Date: Sat, 29 Aug 2026 16:34:11 +0600 Subject: [PATCH 1/2] Add playground example for anchor-less steps transition bug (#549) --- apps/playground/src/examples/anchorless.ts | 62 ++++++++++++++++++++++ apps/playground/src/examples/index.ts | 2 + 2 files changed, 64 insertions(+) create mode 100644 apps/playground/src/examples/anchorless.ts diff --git a/apps/playground/src/examples/anchorless.ts b/apps/playground/src/examples/anchorless.ts new file mode 100644 index 00000000..a7ef1c2f --- /dev/null +++ b/apps/playground/src/examples/anchorless.ts @@ -0,0 +1,62 @@ +import { driver } from "driver.js"; +import type { ExampleGroup } from "./types"; + +// Reproduces upstream issue #549: tour with multiple CONSECUTIVE steps that +// are not attached to any element. Each one renders a centered modal-style +// popover over the shared dummy element. Switching between them flashes a +// small white square at the popover's reset position (top:0 / right:0). +// +// The slow duration makes any flash easy to spot. Use Next/Previous (or the +// arrow keys) to move between steps and watch the popover area. + +const anchorlessSteps = [0, 1, 2, 3].map(i => ({ + popover: { + title: `Step ${i + 1}`, + description: + `This popover is NOT attached to any element. Press Next to switch ` + + `to the next anchor-less step and watch for a white square flash.`, + }, +})); + +function runAnchorless(duration: number) { + driver({ + animate: true, + duration, + showProgress: true, + showButtons: ["next", "previous", "close"], + steps: anchorlessSteps, + }).drive(); +} + +export const anchorlessGroup: ExampleGroup = { + title: "Anchor-less Transition (test)", + examples: [ + { + id: "anchorless-fast", + title: "Fast (150ms)", + description: + "LOOK: quick switch between anchor-less steps — any white flash is brief.", + run() { + runAnchorless(150); + }, + }, + { + id: "anchorless-default", + title: "Default (400ms)", + description: + "LOOK: the reported bug — a white square flashes while switching between anchor-less steps.", + run() { + runAnchorless(400); + }, + }, + { + id: "anchorless-slow", + title: "Slow (1500ms)", + description: + "LOOK: exaggerated duration. The white square (popover at top:0/right:0) is easy to see while moving between steps.", + run() { + runAnchorless(1500); + }, + }, + ], +}; diff --git a/apps/playground/src/examples/index.ts b/apps/playground/src/examples/index.ts index c1d0d052..f76db33a 100644 --- a/apps/playground/src/examples/index.ts +++ b/apps/playground/src/examples/index.ts @@ -10,6 +10,7 @@ import { instancesGroup } from "./instances"; import { skipMissingGroup } from "./skip-missing"; import { advanceWaitGroup } from "./advance-wait"; import { hintsGroup } from "./hints"; +import { anchorlessGroup } from "./anchorless"; export const exampleGroups: ExampleGroup[] = [ highlightGroup, @@ -23,6 +24,7 @@ export const exampleGroups: ExampleGroup[] = [ durationGroup, scrollGroup, apiGroup, + anchorlessGroup, ]; export const examples: Example[] = exampleGroups.flatMap(group => group.examples); From b17fce05121815038c328d3ac09bc91955e5fb43 Mon Sep 17 00:00:00 2001 From: "Md. Saikat Islam" Date: Tue, 1 Sep 2026 22:50:34 +0600 Subject: [PATCH 2/2] fix: eliminate popover flash and stage cutout jump between anchorless steps - Prevent unpositioned popover flash in popover.ts by hiding wrapper during position recalculation - Ensure smooth delayed popover transitions in highlight.ts for consecutive anchorless steps - Animate stage cutout collapse and expansion in-place at element centers in overlay.ts - Omit SVG cutout path for zero-size dummy element stages in stage.ts - Update unit tests in highlight.test.ts, overlay.test.ts, and stage.test.ts --- packages/driver/src/highlight.ts | 14 ++++---- packages/driver/src/overlay.ts | 43 +++++++++++++++++-------- packages/driver/src/popover.ts | 4 ++- packages/driver/src/stage.ts | 5 +++ packages/driver/tests/highlight.test.ts | 15 +++++++++ packages/driver/tests/overlay.test.ts | 1 + packages/driver/tests/stage.test.ts | 8 +++++ 7 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/driver/src/highlight.ts b/packages/driver/src/highlight.ts index d6437632..f804c106 100644 --- a/packages/driver/src/highlight.ts +++ b/packages/driver/src/highlight.ts @@ -12,15 +12,12 @@ function mountDummyElement(): Element { } let element = document.createElement("div"); - element.id = "driver-dummy-element"; element.style.width = "0"; element.style.height = "0"; element.style.pointerEvents = "none"; element.style.opacity = "0"; - element.style.position = "fixed"; - element.style.top = "50%"; - element.style.left = "50%"; + element.style.display = "none"; document.body.appendChild(element); @@ -64,9 +61,9 @@ function transferHighlight(ctx: Context, toElement: Element, toStep: DriveStep) // If it's the first time we're highlighting an element, we show // the popover immediately. Otherwise, we wait for the animation // to finish before showing the popover. - const isFirstHighlight = !fromElement || fromElement === toElement; const isToDummyElement = toElement.id === "driver-dummy-element"; const isFromDummyElement = fromElement.id === "driver-dummy-element"; + const isFirstHighlight = !fromStep || !fromElement || (fromElement === toElement && !isToDummyElement); const isAnimatedTour = ctx.getConfig("animate"); const highlightStartedHook = toStep.onHighlightStarted || ctx.getConfig("onHighlightStarted"); @@ -149,7 +146,12 @@ function transferHighlight(ctx: Context, toElement: Element, toStep: DriveStep) fromElement.removeAttribute("aria-expanded"); fromElement.removeAttribute("aria-controls"); - const disableActiveInteraction = toStep.disableActiveInteraction ?? ctx.getConfig("disableActiveInteraction"); + if (!isToDummyElement) { + document.getElementById("driver-dummy-element")?.remove(); + } + + const disableActiveInteraction = + isToDummyElement || (toStep.disableActiveInteraction ?? ctx.getConfig("disableActiveInteraction")); if (disableActiveInteraction) { toElement.classList.add("driver-no-interaction"); } diff --git a/packages/driver/src/overlay.ts b/packages/driver/src/overlay.ts index 7bcefcb6..7973ffca 100644 --- a/packages/driver/src/overlay.ts +++ b/packages/driver/src/overlay.ts @@ -3,28 +3,37 @@ import { destroyDriverClick, onDriverClick } from "./click"; import { Context } from "./context"; import { generateStageSvgPathString, StageDefinition } from "./stage"; +const centerOf = (rect: StageDefinition): StageDefinition => ({ + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + width: 0, + height: 0, +}); + +const ZERO_RECT: StageDefinition = { x: 0, y: 0, width: 0, height: 0 }; + // This method calculates the animated new position of the // stage (called for each frame by requestAnimationFrame) export function transitionStage(ctx: Context, elapsed: number, duration: number, from: Element, to: Element) { - let activeStagePosition = ctx.getState("__activeStagePosition"); + const activeStagePosition = ctx.getState("__activeStagePosition"); + const isFromDummy = from?.id === "driver-dummy-element"; + const isToDummy = to?.id === "driver-dummy-element"; + + const realFrom = activeStagePosition || (!isFromDummy ? from.getBoundingClientRect() : null); + const realTo = !isToDummy ? to.getBoundingClientRect() : null; - const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect(); - const toDefinition = to.getBoundingClientRect(); + const fromDefinition = isFromDummy ? (realTo ? centerOf(realTo) : ZERO_RECT) : (realFrom || ZERO_RECT); + const toDefinition = isToDummy ? (realFrom ? centerOf(realFrom) : ZERO_RECT) : (realTo || ZERO_RECT); const x = easeInOutQuad(elapsed, fromDefinition.x, toDefinition.x - fromDefinition.x, duration); const y = easeInOutQuad(elapsed, fromDefinition.y, toDefinition.y - fromDefinition.y, duration); const width = easeInOutQuad(elapsed, fromDefinition.width, toDefinition.width - fromDefinition.width, duration); const height = easeInOutQuad(elapsed, fromDefinition.height, toDefinition.height - fromDefinition.height, duration); - activeStagePosition = { - x, - y, - width, - height, - }; + const nextStagePosition = { x, y, width, height }; - renderOverlay(ctx, activeStagePosition); - ctx.setState("__activeStagePosition", activeStagePosition); + renderOverlay(ctx, nextStagePosition); + ctx.setState("__activeStagePosition", nextStagePosition); } export function trackActiveElement(ctx: Context, element: Element) { @@ -32,7 +41,8 @@ export function trackActiveElement(ctx: Context, element: Element) { return; } - const definition = element.getBoundingClientRect(); + const isDummy = element?.id === "driver-dummy-element"; + const definition = isDummy ? { x: 0, y: 0, width: 0, height: 0 } : element.getBoundingClientRect(); const activeStagePosition: StageDefinition = { x: definition.x, @@ -100,9 +110,14 @@ function renderOverlay(ctx: Context, stagePosition: StageDefinition) { } function stageOptions(ctx: Context) { + const activeStep = ctx.getState("activeStep") || ctx.getState("__activeStep"); + const activeElement = ctx.getState("activeElement") || ctx.getState("__activeElement"); + const isDummy = !activeStep?.element || activeElement?.id === "driver-dummy-element"; + return { - padding: ctx.getConfig("stagePadding") || 0, - radius: ctx.getConfig("stageRadius") || 0, + padding: isDummy ? 0 : ctx.getConfig("stagePadding") || 0, + radius: isDummy ? 0 : ctx.getConfig("stageRadius") || 0, + isDummy, }; } diff --git a/packages/driver/src/popover.ts b/packages/driver/src/popover.ts index 4aef6b1b..6fecd1d6 100644 --- a/packages/driver/src/popover.ts +++ b/packages/driver/src/popover.ts @@ -150,8 +150,9 @@ export function renderPopover(anchor: Element, options: PopoverRenderOptions): P popover.closeButton.classList.add("driver-popover-btn-disabled"); } - // Reset the popover position + // Reset the popover position while keeping it hidden until positioned const popoverWrapper = popover.wrapper; + popoverWrapper.style.visibility = "hidden"; popoverWrapper.style.display = "block"; popoverWrapper.style.left = ""; popoverWrapper.style.top = ""; @@ -205,6 +206,7 @@ export function renderPopover(anchor: Element, options: PopoverRenderOptions): P options.onRender?.(popover); repositionPopover(popover, anchor, options.position); + popoverWrapper.style.visibility = ""; repositionOnImagesLoad(popover, anchor, options.position); bringInView(popoverWrapper, options.smoothScroll); diff --git a/packages/driver/src/stage.ts b/packages/driver/src/stage.ts index a28a48cd..f441bfc1 100644 --- a/packages/driver/src/stage.ts +++ b/packages/driver/src/stage.ts @@ -12,6 +12,7 @@ export type StageDefinition = { export type StageOptions = { padding: number; radius: number; + isDummy?: boolean; }; // The full-screen dim with a rounded cutout, as a single evenodd path. @@ -19,6 +20,10 @@ export function generateStageSvgPathString(stage: StageDefinition, options: Stag const windowX = window.innerWidth; const windowY = window.innerHeight; + if (options.isDummy) { + return `M${windowX},0L0,0L0,${windowY}L${windowX},${windowY}L${windowX},0Z`; + } + const stagePadding = options.padding; const stageRadius = options.radius; diff --git a/packages/driver/tests/highlight.test.ts b/packages/driver/tests/highlight.test.ts index f3d4a8b6..b3afcdce 100644 --- a/packages/driver/tests/highlight.test.ts +++ b/packages/driver/tests/highlight.test.ts @@ -192,3 +192,18 @@ describe("step data", () => { expect(step.data).toEqual({ id: 7 }); }); }); + +describe("anchorless steps", () => { + it("mounts driver-dummy-element with display: none so it remains hidden", () => { + const d = createDriver({ + animate: false, + steps: [{ popover: { title: "Anchorless Step" } }], + }); + d.drive(); + + const dummy = document.getElementById("driver-dummy-element"); + expect(dummy).not.toBeNull(); + expect(dummy?.style.display).toBe("none"); + }); +}); + diff --git a/packages/driver/tests/overlay.test.ts b/packages/driver/tests/overlay.test.ts index 9995c1ac..e02420e8 100644 --- a/packages/driver/tests/overlay.test.ts +++ b/packages/driver/tests/overlay.test.ts @@ -80,3 +80,4 @@ describe("overlay configuration", () => { expect(d.isActive()).toBe(true); }); }); + diff --git a/packages/driver/tests/stage.test.ts b/packages/driver/tests/stage.test.ts index 0492ce15..fd609ba4 100644 --- a/packages/driver/tests/stage.test.ts +++ b/packages/driver/tests/stage.test.ts @@ -27,4 +27,12 @@ describe("generateStageSvgPathString", () => { expect(path).toContain("a0,0"); }); + + it("returns full viewport rectangle with no cutout subpaths when stage size is zero", () => { + const zeroStage = { x: 50, y: 50, width: 0, height: 0 }; + const path = generateStageSvgPathString(zeroStage, { padding: 0, radius: 0, isDummy: true }); + const { innerWidth: w, innerHeight: h } = window; + + expect(path).toBe(`M${w},0L0,0L0,${h}L${w},${h}L${w},0Z`); + }); });