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