diff --git a/shepherd.js/src/components/shepherd-modal.ts b/shepherd.js/src/components/shepherd-modal.ts
index 033eb08db..465dfcb91 100644
--- a/shepherd.js/src/components/shepherd-modal.ts
+++ b/shepherd.js/src/components/shepherd-modal.ts
@@ -292,13 +292,16 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
}
}
- const { overflowY } = style;
-
- return (
- overflowY !== 'hidden' &&
- overflowY !== 'visible' &&
- el.scrollHeight >= el.clientHeight
- );
+ // Overflow does not apply to non-replaced inlines, whose rect is just the
+ // union of their line boxes, and `display: contents` generates no box at
+ // all (its rect is 0x0 at the origin). Neither crops anything, whatever
+ // their computed overflow says.
+ const { display, overflowY } = style;
+ if (display === 'inline' || display === 'contents') return false;
+
+ // `hidden` and `clip` crop exactly like `auto` and `scroll`; they only
+ // differ in whether the user can scroll the clipped content into view.
+ return overflowY !== 'visible';
}
/**
diff --git a/shepherd.js/test/cypress/examples/overflow-clipping.html b/shepherd.js/test/cypress/examples/overflow-clipping.html
new file mode 100644
index 000000000..bcf14e2a0
--- /dev/null
+++ b/shepherd.js/test/cypress/examples/overflow-clipping.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
Accordion header
+
+
Collapsed content
+
+
+
+
+ Inline Inline block
+
+
+
+
+
Inside display: contents
+
+
+
+
diff --git a/shepherd.js/test/cypress/integration/overflow-clipping.cy.js b/shepherd.js/test/cypress/integration/overflow-clipping.cy.js
new file mode 100644
index 000000000..5c7f9444c
--- /dev/null
+++ b/shepherd.js/test/cypress/integration/overflow-clipping.cy.js
@@ -0,0 +1,76 @@
+import setupTour from '../utils/setup-tour';
+import overlayOpenings from '../utils/overlay-openings';
+
+// End-to-end guard for #3484. Which ancestors crop a highlight depends on
+// layout (line boxes, box generation), which happy-dom does not implement, so
+// this is the only place the real behaviour is exercised.
+describe('modal overlay clipping by overflow ancestors', () => {
+ let Shepherd, tour;
+
+ beforeEach(() => {
+ Shepherd = null;
+
+ cy.visit('/test/cypress/examples/overflow-clipping', {
+ onLoad(contentWindow) {
+ if (contentWindow.Shepherd) {
+ return (Shepherd = contentWindow.Shepherd);
+ }
+ }
+ });
+ });
+
+ afterEach(() => {
+ tour?.complete();
+ });
+
+ const startTour = (stepOptions) => {
+ cy.document().then(() => {
+ tour = setupTour(
+ Shepherd,
+ { scrollTo: false },
+ () => [{ id: 'clipping', text: 'Clipping', ...stepOptions }],
+ { useModalOverlay: true }
+ );
+ tour.start();
+ });
+ cy.wait(250);
+ };
+
+ /** The opening cut for `selector`, matched by its left edge. */
+ const openingFor = (doc, selector) => {
+ const rect = doc.querySelector(selector).getBoundingClientRect();
+ return overlayOpenings(doc).find(({ x }) => Math.abs(x - rect.left) < 1);
+ };
+
+ it('crops a highlight inside a collapsed `overflow: hidden` ancestor', () => {
+ startTour({
+ attachTo: { element: '.anchor', on: 'bottom' },
+ extraHighlights: ['.collapsed-item']
+ });
+
+ cy.document().then((doc) => {
+ const [anchor, collapsed] = overlayOpenings(doc);
+
+ expect(anchor.height).to.be.closeTo(40, 1);
+ // Without treating `hidden` as cropping, this gets a full 80px hole
+ // over content the user cannot see.
+ expect(collapsed.height).to.equal(0);
+ });
+ });
+
+ it('does not crop by an inline `overflow: hidden` ancestor', () => {
+ startTour({ attachTo: { element: '.inline-item', on: 'bottom' } });
+
+ cy.document().then((doc) => {
+ expect(openingFor(doc, '.inline-item').height).to.be.closeTo(100, 1);
+ });
+ });
+
+ it('does not crop by a `display: contents` ancestor', () => {
+ startTour({ attachTo: { element: '.contents-item', on: 'bottom' } });
+
+ cy.document().then((doc) => {
+ expect(openingFor(doc, '.contents-item').height).to.be.closeTo(60, 1);
+ });
+ });
+});
diff --git a/shepherd.js/test/cypress/integration/root-overflow.cy.js b/shepherd.js/test/cypress/integration/root-overflow.cy.js
index eb89584ec..303b84cc0 100644
--- a/shepherd.js/test/cypress/integration/root-overflow.cy.js
+++ b/shepherd.js/test/cypress/integration/root-overflow.cy.js
@@ -60,6 +60,20 @@ describe('modal overlay with overflow set on the root elements', () => {
});
});
+ it('keeps the opening when a propagated body overflow is `hidden`', () => {
+ // #3484: body's computed overflow-y reads `hidden`, but it applies to the
+ // viewport, which `scrollIntoView` can still scroll.
+ startTour('body { height: 100vh; overflow: hidden; }', true);
+
+ cy.document().then((doc) => {
+ const target = doc.querySelector('.target').getBoundingClientRect();
+ const [opening] = overlayOpenings(doc);
+
+ 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.
diff --git a/shepherd.js/test/unit/components/shepherd-modal.spec.js b/shepherd.js/test/unit/components/shepherd-modal.spec.js
index ded3e1e43..11539e6b6 100644
--- a/shepherd.js/test/unit/components/shepherd-modal.spec.js
+++ b/shepherd.js/test/unit/components/shepherd-modal.spec.js
@@ -529,12 +529,14 @@ describe('components/ShepherdModal', () => {
// 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'.
+ // 'visible'. `displays` maps an element to the `display` it should
+ // report; anything not listed reports 'block'.
function mockOverflow(
overflows,
positions = new Map(),
contains = new Map(),
- overflowXs = new Map()
+ overflowXs = new Map(),
+ displays = new Map()
) {
const spy = vi
.spyOn(window, 'getComputedStyle')
@@ -542,7 +544,8 @@ describe('components/ShepherdModal', () => {
overflowX: overflowXs.get(el) ?? 'visible',
overflowY: overflows.get(el) ?? 'visible',
position: positions.get(el) ?? 'static',
- contain: contains.get(el) ?? 'none'
+ contain: contains.get(el) ?? 'none',
+ display: displays.get(el) ?? 'block'
}));
restoreComputedStyle = () => spy.mockRestore();
return spy;
@@ -985,6 +988,73 @@ describe('components/ShepherdModal', () => {
modal.hide();
});
+ // Regression coverage for https://github.com/shipshapecode/shepherd/issues/3484
+ describe('which overflow ancestors crop', () => {
+ // The ancestor is collapsed to zero height at y 100; its child is laid out
+ // at y 100-180 underneath it.
+ function buildCroppingCase(overflowY, display) {
+ const targetEl = makeChild(container, {
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 50
+ });
+ const ancestor = makeChild(container, {
+ x: 0,
+ y: 100,
+ width: 500,
+ height: 0
+ });
+ const extraEl = makeChild(ancestor, {
+ x: 200,
+ y: 100,
+ width: 100,
+ height: 80
+ });
+
+ mockOverflow(
+ new Map([[ancestor, overflowY]]),
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map([[ancestor, display]])
+ );
+
+ return { targetEl, extraEl };
+ }
+
+ const openingFor = (overflowY, display = 'block') => {
+ const modal = createShepherdModal(container);
+ const { targetEl, extraEl } = buildCroppingCase(overflowY, display);
+ modal.positionModal(0, 0, 0, 0, null, targetEl, [extraEl]);
+ return modal.getElement().querySelector('path').getAttribute('d');
+ };
+
+ it('crops by an `overflow: hidden` ancestor', () => {
+ expect(openingFor('hidden')).not.toContain('V180');
+ });
+
+ it('crops by an `overflow: clip` ancestor', () => {
+ expect(openingFor('clip')).not.toContain('V180');
+ });
+
+ it('crops by an `overflow: auto` ancestor', () => {
+ expect(openingFor('auto')).not.toContain('V180');
+ });
+
+ it('does not crop by an `overflow: visible` ancestor', () => {
+ expect(openingFor('visible')).toContain('V180');
+ });
+
+ it('does not crop by an inline ancestor, whatever its overflow', () => {
+ expect(openingFor('hidden', 'inline')).toContain('V180');
+ });
+
+ it('does not crop by a `display: contents` ancestor', () => {
+ expect(openingFor('hidden', 'contents')).toContain('V180');
+ });
+ });
+
// 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