Skip to content
17 changes: 10 additions & 7 deletions shepherd.js/src/components/shepherd-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
83 changes: 83 additions & 0 deletions shepherd.js/test/cypress/examples/overflow-clipping.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!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>
body {
margin: 0;
padding: 40px;
}

section {
margin-bottom: 40px;
}

.anchor {
width: 200px;
height: 40px;
background: #ccc;
}

/* A collapsed accordion: everything inside is cropped away. */
.collapsed {
height: 0;
overflow: hidden;
}

.collapsed-item {
height: 80px;
background: red;
}

/* Overflow does not apply to non-replaced inlines. */
.inline-hidden {
overflow: hidden;
}

.inline-item {
display: inline-block;
width: 120px;
height: 100px;
background: blue;
}

/* Generates no box, so cannot crop. */
.contents-hidden {
display: contents;
overflow: hidden;
}

.contents-item {
width: 120px;
height: 60px;
background: green;
}
</style>

<section>
<div class="anchor">Accordion header</div>
<div class="collapsed">
<div class="collapsed-item">Collapsed content</div>
</div>
</section>

<section>
<span class="inline-hidden"
>Inline <span class="inline-item">Inline block</span></span
>
</section>

<section>
<div class="contents-hidden">
<div class="contents-item">Inside display: contents</div>
</div>
</section>
</body>
</html>
76 changes: 76 additions & 0 deletions shepherd.js/test/cypress/integration/overflow-clipping.cy.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
14 changes: 14 additions & 0 deletions shepherd.js/test/cypress/integration/root-overflow.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> no longer `visible`, body keeps its overflow and is a real
// scroll container whose rect crops the target below its fold.
Expand Down
76 changes: 73 additions & 3 deletions shepherd.js/test/unit/components/shepherd-modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -529,20 +529,23 @@ 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')
.mockImplementation((el) => ({
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;
Expand Down Expand Up @@ -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 <html> is
// `overflow: visible`, applies to the viewport rather than to the
Expand Down
Loading