Skip to content
Merged
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
13 changes: 12 additions & 1 deletion shepherd.js/src/components/shepherd-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,21 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {

if (!el) return offset;

// The overlay is `position: fixed` in the document it is rendered into, so
// frame offsets only accumulate up to that document's window. Walking on
// to `window.top` would also add the offsets of the frames hosting
// Shepherd itself, pushing the opening off the target (#3478).
const modalWindow = container.ownerDocument.defaultView;
let targetWindow: Window | null = el.ownerDocument.defaultView;

try {
while (targetWindow && targetWindow !== window.top) {
while (
targetWindow &&
targetWindow !== modalWindow &&
// The top window is its own parent; stop there if the target is not
// nested under the modal's window at all.
targetWindow !== targetWindow.parent
) {
const targetIframe = targetWindow?.frameElement;

if (targetIframe) {
Expand Down
21 changes: 21 additions & 0 deletions shepherd.js/test/cypress/examples/iframes/content.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<style>
body {
margin: 0;
padding: 20px 0 0 10px;
}

.target {
width: 100px;
height: 40px;
background: red;
}
</style>
</head>

<body>
<div class="target">Target</div>
</body>
</html>
27 changes: 27 additions & 0 deletions shepherd.js/test/cypress/examples/iframes/host.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="../../../../dist/css/shepherd.css" />
<script type="module">
import Shepherd from '../../../../dist/js/shepherd.mjs';
window.Shepherd = Shepherd;
</script>
<style>
body {
margin: 0;
padding: 60px 0 0 30px;
}

iframe {
border: 0;
width: 400px;
height: 300px;
}
</style>
</head>

<body>
<!-- Holds the target, one frame below Shepherd's own document. -->
<iframe id="content" src="content.html"></iframe>
</body>
</html>
22 changes: 22 additions & 0 deletions shepherd.js/test/cypress/examples/iframes/nested.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html>
<head>
<style>
body {
margin: 0;
padding: 100px 0 0 50px;
}

iframe {
border: 0;
width: 700px;
height: 500px;
}
</style>
</head>

<body>
<!-- Shepherd runs inside this frame, not in the top document. -->
<iframe id="host" src="host.html"></iframe>
</body>
</html>
83 changes: 83 additions & 0 deletions shepherd.js/test/cypress/integration/iframes.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import setupTour from '../utils/setup-tour';
import overlayOpenings from '../utils/overlay-openings';

// End-to-end guard for #3478. The overlay opening for a target in a nested
// frame must be offset only by the frames between the target and the document
// Shepherd renders into, not by the frames hosting Shepherd itself. Unit tests
// can only mock the frame chain, so the arithmetic is checked here.
describe('modal overlay for targets inside iframes', () => {
let tour;

afterEach(() => {
tour?.complete();
});

/** Waits for a same-origin frame's window to satisfy `isReady`. */
const frameWindow = ($frame, isReady) =>
cy
.wrap($frame)
.its('0.contentWindow')
.should((win) => expect(isReady(win)).to.be.ok);

const expectOpeningOnTarget = (shepherdWindow) => {
const doc = shepherdWindow.document;
const content = doc.getElementById('content');
const frameRect = content.getBoundingClientRect();
const targetRect = content.contentDocument
.querySelector('.target')
.getBoundingClientRect();
const [opening] = overlayOpenings(doc);

expect(opening.x).to.be.closeTo(frameRect.left + targetRect.left, 1);
expect(opening.y).to.be.closeTo(frameRect.top + targetRect.top, 1);
expect(opening.height).to.be.closeTo(40, 1);
};

const startTour = (shepherdWindow) => {
const target = shepherdWindow.document
.getElementById('content')
.contentDocument.querySelector('.target');

tour = setupTour(
shepherdWindow.Shepherd,
{ scrollTo: false },
() => [
{
attachTo: { element: target, on: 'bottom' },
id: 'iframe-target',
text: 'Inside an iframe'
}
],
{ useModalOverlay: true }
);
tour.start();
};

const contentReady = (win) =>
win.Shepherd &&
win.document
.getElementById('content')
?.contentDocument?.querySelector('.target');

it('offsets the opening by the target frame when Shepherd is in the top document', () => {
cy.visit('/test/cypress/examples/iframes/host.html');

cy.window()
.should((win) => expect(contentReady(win)).to.be.ok)
.then((win) => {
startTour(win);
cy.wait(250).then(() => expectOpeningOnTarget(win));
});
});

it('does not add the offset of the frame hosting Shepherd', () => {
cy.visit('/test/cypress/examples/iframes/nested.html');

cy.get('#host').then(($host) => {
frameWindow($host, contentReady).then((win) => {
startTour(win);
cy.wait(250).then(() => expectOpeningOnTarget(win));
});
});
});
});
26 changes: 26 additions & 0 deletions shepherd.js/test/cypress/utils/overlay-openings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Reads the cutouts of the modal overlay back out of its svg path.
*
* The path is the full-viewport rect followed by one sub-path per opening,
* each starting `M{x + radius},{y}` and reaching its bottom edge with the
* first `V{y + height}`. Only valid for openings without a corner radius.
*
* @param {Document} doc The document the overlay is rendered in
* @returns {{ x: number, y: number, height: number }[]}
*/
export default function overlayOpenings(doc) {
const d = doc
.querySelector('.shepherd-modal-overlay-container path')
.getAttribute('d');

return d
.split('Z')
.slice(1)
.filter(Boolean)
.map((subPath) => {
const [, x, y, bottom] = subPath.match(
/^M([-\d.e]+),([-\d.e]+).*?V([-\d.e]+)/
);
return { x: Number(x), y: Number(y), height: Number(bottom) - Number(y) };
});
}
Loading
Loading