diff --git a/shepherd.js/src/components/shepherd-modal.ts b/shepherd.js/src/components/shepherd-modal.ts
index b7cf55318..033eb08db 100644
--- a/shepherd.js/src/components/shepherd-modal.ts
+++ b/shepherd.js/src/components/shepherd-modal.ts
@@ -254,6 +254,10 @@ 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.
*
@@ -261,6 +265,33 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
* @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 is `overflow: visible` on both axes,
'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 (
diff --git a/shepherd.js/test/cypress/examples/root-overflow.html b/shepherd.js/test/cypress/examples/root-overflow.html
new file mode 100644
index 000000000..399eb8d41
--- /dev/null
+++ b/shepherd.js/test/cypress/examples/root-overflow.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Target
+
+
+
diff --git a/shepherd.js/test/cypress/integration/root-overflow.cy.js b/shepherd.js/test/cypress/integration/root-overflow.cy.js
new file mode 100644
index 000000000..eb89584ec
--- /dev/null
+++ b/shepherd.js/test/cypress/integration/root-overflow.cy.js
@@ -0,0 +1,77 @@
+import setupTour from '../utils/setup-tour';
+import overlayOpenings from '../utils/overlay-openings';
+
+// End-to-end guard for #1984. Whether 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', () => {
+ // 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 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);
+ });
+ });
+});
diff --git a/shepherd.js/test/cypress/utils/overlay-openings.js b/shepherd.js/test/cypress/utils/overlay-openings.js
new file mode 100644
index 000000000..ad1a2b5b7
--- /dev/null
+++ b/shepherd.js/test/cypress/utils/overlay-openings.js
@@ -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) };
+ });
+}
diff --git a/shepherd.js/test/unit/components/shepherd-modal.spec.js b/shepherd.js/test/unit/components/shepherd-modal.spec.js
index e6c5dee88..ded3e1e43 100644
--- a/shepherd.js/test/unit/components/shepherd-modal.spec.js
+++ b/shepherd.js/test/unit/components/shepherd-modal.spec.js
@@ -526,13 +526,23 @@ describe('components/ShepherdModal', () => {
// unstyled ancestor up to 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;
@@ -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 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 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);
+ // 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