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
+ +
+ +
+ 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