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
31 changes: 31 additions & 0 deletions shepherd.js/src/components/shepherd-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,44 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
_addStepEventListeners();
}

function _isContained(style: CSSStyleDeclaration) {
return Boolean(style.contain) && style.contain !== 'none';
}

/**
* Whether `el` crops overflowing descendants on the y-axis.
*
* @param el The candidate scroll container
* @param style `el`'s computed style, already resolved by the caller
*/
function _isScrollable(el: HTMLElement, style: CSSStyleDeclaration) {
const { documentElement, body } = el.ownerDocument;

// The root element's overflow always applies to the viewport, never to its
// own box, so it does not crop anything against its own rect.
if (el === documentElement) return false;

// While <html> is `overflow: visible` on both axes, <body>'s overflow
// propagates to the viewport as well and body's used overflow becomes
// `visible`, even though its computed value still reads `auto` / `scroll`.
// Clipping against body's rect then zeroes out targets the viewport has
// scrolled to as soon as body is sized to the viewport (e.g. `height:
// 100%`), see #1984.
// Any containment on either element stops that propagation, leaving body
// a scroll container of its own.
if (el === body) {
const rootStyle = window.getComputedStyle(documentElement);

if (
rootStyle.overflowX === 'visible' &&
rootStyle.overflowY === 'visible' &&
!_isContained(rootStyle) &&
!_isContained(style)
) {
return false;
}
}

const { overflowY } = style;

return (
Expand Down
40 changes: 40 additions & 0 deletions shepherd.js/test/cypress/examples/root-overflow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!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>
</head>

<body>
<style>
html,
body {
margin: 0;
}

/* Pushes the target below the initial viewport. */
.spacer {
height: 2000px;
}

.target {
height: 40px;
background: red;
}

.tail {
height: 1000px;
}
</style>

<!-- Each test injects the root overflow styles under test here. -->
<style id="root-styles"></style>

<div class="spacer"></div>
<div class="target">Target</div>
<div class="tail"></div>
</body>
</html>
77 changes: 77 additions & 0 deletions shepherd.js/test/cypress/integration/root-overflow.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import setupTour from '../utils/setup-tour';
import overlayOpenings from '../utils/overlay-openings';

// End-to-end guard for #1984. Whether <body> crops anything depends on overflow
// propagation to the viewport, which happy-dom has no layout engine for, so
// this is the only place the real behaviour is exercised.
describe('modal overlay with overflow set on the root elements', () => {
let Shepherd, tour;

beforeEach(() => {
Shepherd = null;

cy.visit('/test/cypress/examples/root-overflow', {
onLoad(contentWindow) {
if (contentWindow.Shepherd) {
return (Shepherd = contentWindow.Shepherd);
}
}
});
});

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

const startTour = (rootStyles, scrollTo) => {
cy.document().then((doc) => {
doc.getElementById('root-styles').textContent = rootStyles;

tour = setupTour(
Shepherd,
{},
() => [
{
attachTo: { element: '.target', on: 'bottom' },
id: 'root-overflow',
text: 'Below the initial viewport',
scrollTo
}
],
{ useModalOverlay: true }
);
tour.start();
});
cy.wait(250);
};

it('keeps the opening when body overflow propagates to the viewport', () => {
// <html> is `overflow: visible`, so body's `auto` applies to the viewport
// and body itself crops nothing, even though it is viewport sized.
startTour('html, body { height: 100%; } body { overflow: auto; }', true);

cy.document().then((doc) => {
const target = doc.querySelector('.target').getBoundingClientRect();
const [opening] = overlayOpenings(doc);

expect(target.top).to.be.within(0, doc.defaultView.innerHeight - 40);
expect(opening.y).to.be.closeTo(target.top, 1);
expect(opening.height).to.be.closeTo(40, 1);
});
});

it('still clips by body when body is a scroll container of its own', () => {
// With <html> no longer `visible`, body keeps its overflow and is a real
// scroll container whose rect crops the target below its fold.
startTour(
'html { height: 100%; overflow: hidden; } body { height: 100%; overflow: auto; }',
false
);

cy.document().then((doc) => {
const [opening] = overlayOpenings(doc);

expect(opening.height).to.equal(0);
});
});
});
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) };
});
}
129 changes: 126 additions & 3 deletions shepherd.js/test/unit/components/shepherd-modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -526,13 +526,23 @@ describe('components/ShepherdModal', () => {
// unstyled ancestor up to <html> would count as a scroll container.
// `positions` maps an element to the `position` it should report, which
// decides whether a scrollable ancestor actually crops it. Anything not
// listed reports 'static', matching an ordinary element.
function mockOverflow(overflows, positions = new Map()) {
// listed reports 'static', matching an ordinary element. `contains`
// maps an element to its `contain` value; anything else reports 'none'.
// `overflowXs` maps an element to its `overflowX`; anything else reports
// 'visible'.
function mockOverflow(
overflows,
positions = new Map(),
contains = new Map(),
overflowXs = new Map()
) {
const spy = vi
.spyOn(window, 'getComputedStyle')
.mockImplementation((el) => ({
overflowX: overflowXs.get(el) ?? 'visible',
overflowY: overflows.get(el) ?? 'visible',
position: positions.get(el) ?? 'static'
position: positions.get(el) ?? 'static',
contain: contains.get(el) ?? 'none'
}));
restoreComputedStyle = () => spy.mockRestore();
return spy;
Expand Down Expand Up @@ -975,6 +985,119 @@ describe('components/ShepherdModal', () => {
modal.hide();
});

// Regression coverage for https://github.com/shipshapecode/shepherd/issues/1984
// The root element's overflow, and body's whenever <html> is
// `overflow: visible`, applies to the viewport rather than to the
// element's own box, so neither may crop a highlight against its rect.
describe('root elements', () => {
afterEach(() => {
delete document.body.getBoundingClientRect;
delete document.documentElement.getBoundingClientRect;
});

// Body sized to the viewport (`height: 100%`) and scrolled entirely
// above it, while the highlight sits fully on screen at y 200-240.
function buildRootCase(overflows, contains, overflowXs) {
const targetEl = makeChild(container, {
x: 10,
y: 10,
width: 100,
height: 50
});
const extraEl = makeChild(container, {
x: 200,
y: 200,
width: 100,
height: 40
});
const offscreen = { x: 0, y: -1000, width: 1000, height: 700 };
stubRect(document.body, offscreen);
stubRect(document.documentElement, offscreen);

mockOverflow(overflows, new Map(), contains, overflowXs);

return { targetEl, extraEl };
}

it('does not clip by body when its overflow propagates to the viewport', () => {
const modal = createShepherdModal(container);
const { targetEl, extraEl } = buildRootCase(
new Map([[document.body, 'auto']])
);

modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);

const d = modal.getElement().querySelector('path').getAttribute('d');
expect(d).toContain('M200,200');
expect(d).toContain('V240');
});

it('never clips by the root element', () => {
const modal = createShepherdModal(container);
const { targetEl, extraEl } = buildRootCase(
new Map([[document.documentElement, 'auto']])
);

modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);

const d = modal.getElement().querySelector('path').getAttribute('d');
expect(d).toContain('M200,200');
expect(d).toContain('V240');
});

it('still clips by body when containment stops the propagation', () => {
for (const contained of [document.body, document.documentElement]) {
const modal = createShepherdModal(container);
const { targetEl, extraEl } = buildRootCase(
new Map([[document.body, 'auto']]),
new Map([[contained, 'paint']])
);

modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);

const d = modal
.getElement()
.querySelector('path')
.getAttribute('d');
expect(d).not.toContain('V240');
restoreComputedStyle();
}
});

it('still clips by body when <html> only clips the other axis', () => {
const modal = createShepherdModal(container);
// `overflow-x: clip` alone leaves overflow-y computing to `visible`,
// but body's overflow no longer propagates.
const { targetEl, extraEl } = buildRootCase(
new Map([[document.body, 'auto']]),
new Map(),
new Map([[document.documentElement, 'clip']])
);

modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);

const d = modal.getElement().querySelector('path').getAttribute('d');
expect(d).not.toContain('V240');
});

it('still clips by body when body is its own scroll container', () => {
const modal = createShepherdModal(container);
// <html> no longer `visible`, so body's overflow stays on body.
const { targetEl, extraEl } = buildRootCase(
new Map([
[document.documentElement, 'hidden'],
[document.body, 'auto']
])
);

modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);

const d = modal.getElement().querySelector('path').getAttribute('d');
// Clipped to body's bottom edge at y -300, so no height is left.
expect(d).not.toContain('V240');
});
});

// A scrollable DOM ancestor only crops a descendant when it is in that
// descendant's containing block chain. Resolving a scroll parent per
// element made this matter: without the containing block check, an
Expand Down
Loading