Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/playground/src/examples/anchorless.ts
Original file line number Diff line number Diff line change
@@ -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);
},
},
],
};
2 changes: 2 additions & 0 deletions apps/playground/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ export const exampleGroups: ExampleGroup[] = [
durationGroup,
scrollGroup,
apiGroup,
anchorlessGroup,
];

export const examples: Example[] = exampleGroups.flatMap(group => group.examples);
Expand Down
14 changes: 8 additions & 6 deletions packages/driver/src/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
Expand Down
43 changes: 29 additions & 14 deletions packages/driver/src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,46 @@ 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) {
if (!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,
Expand Down Expand Up @@ -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,
};
}

Expand Down
4 changes: 3 additions & 1 deletion packages/driver/src/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions packages/driver/src/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ 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.
export function generateStageSvgPathString(stage: StageDefinition, options: StageOptions) {
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;

Expand Down
15 changes: 15 additions & 0 deletions packages/driver/tests/highlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

1 change: 1 addition & 0 deletions packages/driver/tests/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ describe("overlay configuration", () => {
expect(d.isActive()).toBe(true);
});
});

8 changes: 8 additions & 0 deletions packages/driver/tests/stage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
});