/** * Copyright 2016 Google Inc. All Rights Reserved. * * Licensed under the W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE. * * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document * */ // Sets the timeout to three times the poll interval to ensure all updates // happen (especially in slower browsers). Native implementations get the // standard 100ms timeout defined in the spec. var ASYNC_TIMEOUT = IntersectionObserver.prototype.THROTTLE_TIMEOUT * 3 || 100; var io; var noop = function() {}; // References to DOM elements, which are accessible to any test // and reset prior to each test so state isn't shared. var rootEl; var grandParentEl; var parentEl; var targetEl1; var targetEl2; var targetEl3; var targetEl4; describe('IntersectionObserver', function() { before(function() { // If the browser running the tests doesn't support MutationObserver, // fall back to polling. if (!('MutationObserver' in window)) { IntersectionObserver.prototype.POLL_INTERVAL = IntersectionObserver.prototype.THROTTLE_TIMEOUT || 100; } }); beforeEach(function() { addStyles(); addFixtures(); }); afterEach(function() { if (io && 'disconnect' in io) io.disconnect(); io = null; removeStyles(); removeFixtures(); }); describe('constructor', function() { it('throws when callback is not a function', function() { expect(function() { io = new IntersectionObserver(null); }).to.throwException(); }); it('instantiates root correctly', function() { io = new IntersectionObserver(noop); expect(io.root).to.be(null); io = new IntersectionObserver(noop, {root: document}); expect(io.root).to.be(document); io = new IntersectionObserver(noop, {root: rootEl}); expect(io.root).to.be(rootEl); }); it('throws when root is not a Document or Element', function() { expect(function() { io = new IntersectionObserver(noop, {root: 'foo'}); }).to.throwException(); }); it('instantiates rootMargin correctly', function() { io = new IntersectionObserver(noop, {rootMargin: '10px'}); expect(io.rootMargin).to.be('10px 10px 10px 10px'); io = new IntersectionObserver(noop, {rootMargin: '10px -5%'}); expect(io.rootMargin).to.be('10px -5% 10px -5%'); io = new IntersectionObserver(noop, {rootMargin: '10px 20% 0px'}); expect(io.rootMargin).to.be('10px 20% 0px 20%'); io = new IntersectionObserver(noop, {rootMargin: '0px 0px -5% 5px'}); expect(io.rootMargin).to.be('0px 0px -5% 5px'); // TODO(philipwalton): the polyfill supports fractional pixel and // percentage values, but the native Chrome implementation does not, // at least not in what it reports `rootMargin` to be. if (!supportsNativeIntersectionObserver()) { io = new IntersectionObserver(noop, {rootMargin: '-2.5% -8.5px'}); expect(io.rootMargin).to.be('-2.5% -8.5px -2.5% -8.5px'); } }); // TODO(philipwalton): this doesn't throw in FF, consider readding once // expected behavior is clarified. // it('throws when rootMargin is not in pixels or pecernt', function() { // expect(function() { // io = new IntersectionObserver(noop, {rootMargin: '0'}); // }).to.throwException(); // }); // Chrome's implementation in version 51 doesn't include the thresholds // property, but versions 52+ do. if ('thresholds' in IntersectionObserver.prototype) { it('instantiates thresholds correctly', function() { io = new IntersectionObserver(noop); expect(io.thresholds).to.eql([0]); io = new IntersectionObserver(noop, {threshold: 0.5}); expect(io.thresholds).to.eql([0.5]); io = new IntersectionObserver(noop, {threshold: [0.25, 0.5, 0.75]}); expect(io.thresholds).to.eql([0.25, 0.5, 0.75]); io = new IntersectionObserver(noop, {threshold: [1, .5, 0]}); expect(io.thresholds).to.eql([0, .5, 1]); }); } it('throws when a threshold is not a number', function() { expect(function() { io = new IntersectionObserver(noop, {threshold: ['foo']}); }).to.throwException(); }); it('throws when a threshold value is not between 0 and 1', function() { expect(function() { io = new IntersectionObserver(noop, {threshold: [0, -1]}); }).to.throwException(); }); }); describe('observe', function() { it('throws when target is not an Element', function() { expect(function() { io = new IntersectionObserver(noop); io.observe(null); }).to.throwException(); }); it('fills in x and y in the resulting rects', function(done) { io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); var entry = records[0]; expect(entry.rootBounds.x).to.be(entry.rootBounds.left); expect(entry.rootBounds.y).to.be(entry.rootBounds.top); expect(entry.boundingClientRect.x).to.be(entry.boundingClientRect.left); expect(entry.boundingClientRect.y).to.be(entry.boundingClientRect.top); expect(entry.intersectionRect.x).to.be(entry.intersectionRect.left); expect(entry.intersectionRect.y).to.be(entry.intersectionRect.top); done(); }, {root: rootEl}); targetEl2.style.top = '-40px'; io.observe(targetEl1); }); it('triggers for all targets when observing begins', function(done) { io = new IntersectionObserver(function(records) { expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[1].intersectionRatio).to.be(0); done(); }, {root: rootEl}); targetEl2.style.top = '-40px'; io.observe(targetEl1); io.observe(targetEl2); }); it('triggers for existing targets when observing begins after monitoring has begun', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); io.observe(targetEl1); setTimeout(function() { io.observe(targetEl2); setTimeout(function() { expect(spy.callCount).to.be(2); done(); }, ASYNC_TIMEOUT); }, ASYNC_TIMEOUT); }); it('triggers with the correct arguments', function(done) { io = new IntersectionObserver(function(records, observer) { expect(records.length).to.be(2); expect(records[0] instanceof IntersectionObserverEntry).to.be.ok(); expect(records[1] instanceof IntersectionObserverEntry).to.be.ok(); expect(observer).to.be(io); expect(this).to.be(io); done(); }, {root: rootEl}); targetEl2.style.top = '-40px'; io.observe(targetEl1); io.observe(targetEl2); }); it('handles container elements with non-visible overflow', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); runSequence([ function(done) { io.observe(targetEl1); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.left = '-40px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { parentEl.style.overflow = 'visible'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); } ], done); }); it('observes one target at a single threshold correctly', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl, threshold: 0.5}); runSequence([ function(done) { targetEl1.style.left = '-5px'; io.observe(targetEl1); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be.greaterThan(0.5); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.left = '-15px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be.lessThan(0.5); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.left = '-25px'; setTimeout(function() { expect(spy.callCount).to.be(2); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.left = '-10px'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0.5); done(); }, ASYNC_TIMEOUT); } ], done); }); it('observes multiple targets at multiple thresholds correctly', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, { root: rootEl, threshold: [1, 0.5, 0] }); runSequence([ function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '-15px'; targetEl2.style.top = '-5px'; targetEl2.style.left = '0px'; targetEl3.style.top = '0px'; targetEl3.style.left = '205px'; io.observe(targetEl1); io.observe(targetEl2); io.observe(targetEl3); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(3); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(0.25); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0.75); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '-5px'; targetEl2.style.top = '-15px'; targetEl2.style.left = '0px'; targetEl3.style.top = '0px'; targetEl3.style.left = '195px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(3); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(0.75); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0.25); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0.25); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '5px'; targetEl2.style.top = '-25px'; targetEl2.style.left = '0px'; targetEl3.style.top = '0px'; targetEl3.style.left = '185px'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(3); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(1); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0.75); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '15px'; targetEl2.style.top = '-35px'; targetEl2.style.left = '0px'; targetEl3.style.top = '0px'; targetEl3.style.left = '175px'; setTimeout(function() { expect(spy.callCount).to.be(4); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].target).to.be(targetEl3); expect(records[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); } ], done); }); it('handles rootMargin properly', function(done) { parentEl.style.overflow = 'visible'; targetEl1.style.top = '0px'; targetEl1.style.left = '-20px'; targetEl2.style.top = '-20px'; targetEl2.style.left = '0px'; targetEl3.style.top = '0px'; targetEl3.style.left = '200px'; targetEl4.style.top = '180px'; targetEl4.style.left = '180px'; runSequence([ function(done) { io = new IntersectionObserver(function(records) { records = sortRecords(records); expect(records.length).to.be(4); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(1); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(.5); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(.5); expect(records[3].target).to.be(targetEl4); expect(records[3].intersectionRatio).to.be(1); io.disconnect(); done(); }, {root: rootEl, rootMargin: '10px'}); io.observe(targetEl1); io.observe(targetEl2); io.observe(targetEl3); io.observe(targetEl4); }, function(done) { io = new IntersectionObserver(function(records) { records = sortRecords(records); expect(records.length).to.be(4); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(0.5); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0.5); expect(records[3].target).to.be(targetEl4); expect(records[3].intersectionRatio).to.be(0.5); io.disconnect(); done(); }, {root: rootEl, rootMargin: '-10px 10%'}); io.observe(targetEl1); io.observe(targetEl2); io.observe(targetEl3); io.observe(targetEl4); }, function(done) { io = new IntersectionObserver(function(records) { records = sortRecords(records); expect(records.length).to.be(4); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(0.5); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0); expect(records[3].target).to.be(targetEl4); expect(records[3].intersectionRatio).to.be(0.5); io.disconnect(); done(); }, {root: rootEl, rootMargin: '-5% -2.5% 0px'}); io.observe(targetEl1); io.observe(targetEl2); io.observe(targetEl3); io.observe(targetEl4); }, function(done) { io = new IntersectionObserver(function(records) { records = sortRecords(records); expect(records.length).to.be(4); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(0.5); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(0.5); expect(records[2].target).to.be(targetEl3); expect(records[2].intersectionRatio).to.be(0); expect(records[3].target).to.be(targetEl4); expect(records[3].intersectionRatio).to.be(0.25); io.disconnect(); done(); }, {root: rootEl, rootMargin: '5% -2.5% -10px -190px'}); io.observe(targetEl1); io.observe(targetEl2); io.observe(targetEl3); io.observe(targetEl4); } ], done); }); it('handles targets on the boundary of root', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); runSequence([ function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '-21px'; targetEl2.style.top = '-20px'; targetEl2.style.left = '0px'; io.observe(targetEl1); io.observe(targetEl2); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl1); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(0); expect(records[1].target).to.be(targetEl2); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.top = '0px'; targetEl1.style.left = '-20px'; targetEl2.style.top = '-21px'; targetEl2.style.left = '0px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl1); expect(records[1].intersectionRatio).to.be(0); expect(records[1].target).to.be(targetEl2); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.top = '-20px'; targetEl1.style.left = '200px'; targetEl2.style.top = '200px'; targetEl2.style.left = '200px'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl2); done(); }, ASYNC_TIMEOUT); } ], done); }); it('handles zero-size targets within the root coordinate space', function(done) { io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); done(); }, {root: rootEl}); targetEl1.style.top = '0px'; targetEl1.style.left = '0px'; targetEl1.style.width = '0px'; targetEl1.style.height = '0px'; io.observe(targetEl1); }); it('handles elements with display set to none', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); runSequence([ function(done) { rootEl.style.display = 'none'; io.observe(targetEl1); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { rootEl.style.display = 'block'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }, function(done) { parentEl.style.display = 'none'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { parentEl.style.display = 'block'; setTimeout(function() { expect(spy.callCount).to.be(4); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }, function(done) { targetEl1.style.display = 'none'; setTimeout(function() { expect(spy.callCount).to.be(5); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); } ], done); }); it('handles target elements not yet added to the DOM', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); // targetEl5 is initially not in the DOM. Note that this element must be // created outside of the addFixtures() function to catch the IE11 error // described here: https://github.com/w3c/IntersectionObserver/pull/205 var targetEl5 = document.createElement('div'); targetEl5.setAttribute('id', 'target5'); runSequence([ function(done) { io.observe(targetEl5); setTimeout(function() { // Initial observe should trigger with no intersections since // targetEl5 is not yet in the DOM. expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl5); done(); }, ASYNC_TIMEOUT); }, function(done) { // Adding targetEl5 inside rootEl should trigger. parentEl.insertBefore(targetEl5, targetEl2); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); expect(records[0].target).to.be(targetEl5); done(); }, ASYNC_TIMEOUT); }, function(done) { // Removing an ancestor of targetEl5 should trigger. grandParentEl.parentNode.removeChild(grandParentEl); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl5); done(); }, ASYNC_TIMEOUT); }, function(done) { // Adding the previously removed targetEl5 (via grandParentEl) // back directly inside rootEl should trigger. rootEl.appendChild(targetEl5); setTimeout(function() { expect(spy.callCount).to.be(4); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); expect(records[0].target).to.be(targetEl5); done(); }, ASYNC_TIMEOUT); }, function(done) { // Removing rootEl should trigger. rootEl.parentNode.removeChild(rootEl); setTimeout(function() { expect(spy.callCount).to.be(5); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); expect(records[0].target).to.be(targetEl5); done(); }, ASYNC_TIMEOUT); } ], done); }); if ('attachShadow' in Element.prototype) { it('handles targets in shadow DOM', function(done) { grandParentEl.attachShadow({mode: 'open'}); grandParentEl.shadowRoot.appendChild(parentEl); io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); done(); }, {root: rootEl}); io.observe(targetEl1); }); it('handles roots in shadow DOM', function(done) { var shadowRoot = grandParentEl.attachShadow({mode: 'open'}); shadowRoot.innerHTML = '' + '
'; var slotParent = shadowRoot.getElementById('slot-parent'); io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); done(); }, {root: slotParent}); io.observe(targetEl1); }); } it('handles sub-root element scrolling', function(done) { io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(1); done(); }, {root: rootEl}); io.observe(targetEl3); setTimeout(function() { parentEl.scrollLeft = 40; }, 0); }); // Only run this test in browsers that support CSS transitions. if ('transform' in document.documentElement.style && 'transition' in document.documentElement.style) { it('supports CSS transitions and transforms', function(done) { targetEl1.style.top = '220px'; targetEl1.style.left = '220px'; io = new IntersectionObserver(function(records) { expect(records.length).to.be(1); // Chrome's native implementation sometimes incorrectly reports // the intersection ratio as a number > 1. expect(records[0].intersectionRatio >= 1); done(); }, {root: rootEl, threshold: [1]}); // CSS transitions that are slower than the default throttle timeout // require polling to detect, which can be set on a per-instance basis. if (!supportsNativeIntersectionObserver()) { io.POLL_INTERVAL = 100; } io.observe(targetEl1); setTimeout(function() { targetEl1.style.transform = 'translateX(-40px) translateY(-40px)'; }, 0); }); } it('uses the viewport when no root is specified', function(done) { io = new IntersectionObserver(function(records) { var viewportWidth = document.documentElement.clientWidth || document.body.clientWidth; var viewportHeight = document.documentElement.clientHeight || document.body.clientHeight; expect(records.length).to.be(1); expect(records[0].rootBounds.top).to.be(0); expect(records[0].rootBounds.left).to.be(0); expect(records[0].rootBounds.right).to.be(viewportWidth); expect(records[0].rootBounds.width).to.be(viewportWidth); expect(records[0].rootBounds.bottom).to.be(viewportHeight); expect(records[0].rootBounds.height).to.be(viewportHeight); done(); }); // Ensures targetEl1 is visible in the viewport before observing. window.scrollTo(0, 0); rootEl.style.position = 'absolute'; rootEl.style.top = '0px'; rootEl.style.left = '0px'; io.observe(targetEl1); }); }); describe('takeRecords', function() { it('supports getting records before the callback is invoked', function(done) { var lastestRecords = []; io = new IntersectionObserver(function(records) { lastestRecords = lastestRecords.concat(records); }, {root: rootEl}); io.observe(targetEl1); window.requestAnimationFrame && requestAnimationFrame(function() { lastestRecords = lastestRecords.concat(io.takeRecords()); }); setTimeout(function() { expect(lastestRecords.length).to.be(1); expect(lastestRecords[0].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }); }); describe('unobserve', function() { it('removes targets from the internal store', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); runSequence([ function(done) { targetEl1.style.top = targetEl2.style.top = '0px'; targetEl1.style.left = targetEl2.style.left = '0px'; io.observe(targetEl1); io.observe(targetEl2); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(1); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }, function(done) { io.unobserve(targetEl1); targetEl1.style.top = targetEl2.style.top = '0px'; targetEl1.style.left = targetEl2.style.left = '-40px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].target).to.be(targetEl2); expect(records[0].intersectionRatio).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { io.unobserve(targetEl2); targetEl1.style.top = targetEl2.style.top = '0px'; targetEl1.style.left = targetEl2.style.left = '0px'; setTimeout(function() { expect(spy.callCount).to.be(2); done(); }, ASYNC_TIMEOUT); } ], done); }); }); describe('disconnect', function() { it('removes all targets and stops listening for changes', function(done) { var spy = sinon.spy(); io = new IntersectionObserver(spy, {root: rootEl}); runSequence([ function(done) { targetEl1.style.top = targetEl2.style.top = '0px'; targetEl1.style.left = targetEl2.style.left = '0px'; io.observe(targetEl1); io.observe(targetEl2); setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].target).to.be(targetEl1); expect(records[0].intersectionRatio).to.be(1); expect(records[1].target).to.be(targetEl2); expect(records[1].intersectionRatio).to.be(1); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); targetEl1.style.top = targetEl2.style.top = '0px'; targetEl1.style.left = targetEl2.style.left = '-40px'; setTimeout(function() { expect(spy.callCount).to.be(1); done(); }, ASYNC_TIMEOUT); } ], done); }); }); describe('iframe', function() { var iframe; var iframeWin, iframeDoc; var documentElement, body; var iframeTargetEl1, iframeTargetEl2; var bodyWidth; beforeEach(function(done) { iframe = document.createElement('iframe'); iframe.setAttribute('frameborder', '0'); iframe.setAttribute('scrolling', 'yes'); iframe.style.position = 'fixed'; iframe.style.top = '0px'; iframe.style.width = '100px'; iframe.style.height = '200px'; iframe.onerror = function() { done(new Error('iframe initialization failed')); }; iframe.onload = function() { iframe.onload = null; iframeWin = iframe.contentWindow; iframeDoc = iframeWin.document; iframeDoc.open(); iframeDoc.write(''); iframeDoc.write(''); iframeDoc.close(); // Ensure the documentElement and body are always sorted on top. See // `sortRecords` for more info. documentElement = iframeDoc.documentElement; body = iframeDoc.body; documentElement.id = 'A1'; body.id = 'A1'; function createTarget(id, bg) { var target = iframeDoc.createElement('div'); target.id = id; target.className = 'target'; target.style.background = bg; iframeDoc.body.appendChild(target); return target; } iframeTargetEl1 = createTarget('target1', 'blue'); iframeTargetEl2 = createTarget('target2', 'green'); bodyWidth = iframeDoc.body.clientWidth; done(); }; iframe.src = 'about:blank'; rootEl.appendChild(iframe); }); afterEach(function() { rootEl.removeChild(iframe); }); function rect(r) { return { y: typeof r.y == 'number' ? r.y : r.top, x: typeof r.x == 'number' ? r.x : r.left, top: r.top, left: r.left, width: r.width != null ? r.width : r.right - r.left, height: r.height != null ? r.height : r.bottom - r.top, right: r.right != null ? r.right : r.left + r.width, bottom: r.bottom != null ? r.bottom : r.top + r.height }; } function getRootRect(doc) { var html = doc.documentElement; var body = doc.body; return rect({ top: 0, left: 0, right: html.clientWidth || body.clientWidth, width: html.clientWidth || body.clientWidth, bottom: html.clientHeight || body.clientHeight, height: html.clientHeight || body.clientHeight }); } describe('same-origin iframe loaded in the mainframe', function() { it('iframe targets do not intersect with a top root element', function(done) { var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(records[0].isIntersecting).to.be(false); expect(records[1].isIntersecting).to.be(false); done(); io.disconnect(); }, {root: rootEl}); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('triggers for all targets in top-level root', function(done) { var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(0); // The rootBounds is for the document's root. expect(records[0].rootBounds.height).to.be(innerHeight); done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('triggers for all targets in iframe-level root', function(done) { var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[1].intersectionRatio).to.be(1); // The rootBounds is for the document's root. expect(rect(records[0].rootBounds)). to.eql(rect(iframeDoc.body.getBoundingClientRect())); done(); io.disconnect(); }, {root: iframeDoc.body}); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a fully visible frame', function(done) { iframe.style.top = '0px'; iframe.style.height = '300px'; var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(clientRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); // The target2 is partially visible. var clientRect2 = rect({ top: 202, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.48, 0.5); // ~0.5 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a fully visible and offset frame', function(done) { iframe.style.top = '10px'; iframe.style.height = '300px'; var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(clientRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); // The target2 is partially visible. var clientRect2 = rect({ top: 202, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.48, 0.5); // ~0.5 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a clipped frame on top', function(done) { iframe.style.top = '-10px'; iframe.style.height = '300px'; var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, height: 200 - 10 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(intersectRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.within(0.94, 0.96); // ~0.95 // The target2 is partially visible. var clientRect2 = rect({ top: 202, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.48, 0.5); // ~0.49 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a clipped frame on bottom', function(done) { iframe.style.top = 'auto'; iframe.style.bottom = '-10px'; iframe.style.height = '300px'; var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(clientRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.be(1); // The target2 is partially visible. var clientRect2 = rect({ top: 202, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 - 10 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.43, 0.45); // ~0.44 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a fully visible frame and scrolled', function(done) { iframe.style.top = '0px'; iframe.style.height = '300px'; iframeWin.scrollTo(0, 10); var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is fully visible. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ top: 0, left: 0, width: bodyWidth, // Height is only for the visible area. height: 200 - 10 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(intersectRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.within(0.94, 0.96); // ~0.95 // The target2 is partially visible. var clientRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.53, 0.55); // ~0.54 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a clipped frame on top and scrolled', function(done) { iframe.style.top = '-10px'; iframe.style.height = '300px'; iframeWin.scrollTo(0, 10); var io = new IntersectionObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(2); expect(rect(records[0].rootBounds)).to.eql(getRootRect(document)); expect(rect(records[1].rootBounds)).to.eql(getRootRect(document)); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, // The height is less by both: offset and scroll. height: 200 - 10 - 10 }); expect(rect(records[0].boundingClientRect)).to.eql(clientRect1); expect(rect(records[0].intersectionRect)).to.eql(intersectRect1); expect(records[0].isIntersecting).to.be(true); expect(records[0].intersectionRatio).to.within(0.89, 0.91); // ~0.9 // The target2 is partially visible. var clientRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[1].boundingClientRect)).to.eql(clientRect2); expect(rect(records[1].intersectionRect)).to.eql(intersectRect2); expect(records[1].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be.within(0.53, 0.55); // ~0.54 done(); io.disconnect(); }); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('handles tracking iframe viewport', function(done) { iframe.style.height = '100px'; iframe.style.top = '100px'; iframeWin.scrollTo(0, 110); // {root:iframeDoc} means to track the iframe viewport. var io = new IntersectionObserver( function (records) { io.unobserve(iframeTargetEl1); var intersectionRect = rect({ top: 0, // if root=null, then this would be 100. left: 0, height: 90, width: bodyWidth }); expect(records.length).to.be(1); expect(rect(records[0].rootBounds)).to.eql(getRootRect(iframeDoc)); expect(rect(records[0].intersectionRect)).to.eql(intersectionRect); done(); }, { root: iframeDoc } ); io.observe(iframeTargetEl1); }); it('handles tracking iframe viewport with rootMargin', function(done) { iframe.style.height = '100px'; var io = new IntersectionObserver( function (records) { io.unobserve(iframeTargetEl1); var intersectionRect = rect({ top: 0, // if root=null, then this would be 100. left: 0, height: 200, width: bodyWidth }); // rootMargin: 100% --> 3x width + 3x height. var expectedRootBounds = rect({ top: -100, left: -bodyWidth, width: bodyWidth * 3, height: 100 * 3 }); expect(records.length).to.be(1); expect(rect(records[0].rootBounds)).to.eql(expectedRootBounds); expect(rect(records[0].intersectionRect)).to.eql(intersectionRect); done(); }, { root: iframeDoc, rootMargin: '100%' } ); io.observe(iframeTargetEl1); }); // Current spec indicates that cross-document tracking yields // an essentially empty IntersectionObserverEntry. // See: https://github.com/w3c/IntersectionObserver/issues/87 it('does not track cross-document elements', function(done) { var io = new IntersectionObserver( function (records) { io.unobserve(iframeTargetEl1) expect(records.length).to.be(1); const zeroesRect = rect({ top: 0, left: 0, width: 0, height: 0 }); expect(rect(records[0].rootBounds)).to.eql(zeroesRect); expect(rect(records[0].intersectionRect)).to.eql(zeroesRect); expect(records.isIntersecting).false; done(); }, { root: document } ); io.observe(iframeTargetEl1); }); it('handles style changes', function(done) { var spy = sinon.spy(); // When first element becomes invisible, the second element will show. // And in reverse: when the first element becomes visible again, the // second element will disappear. var io = new IntersectionObserver(spy); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = 'none'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = ''; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles scroll changes', function(done) { var spy = sinon.spy(); // Scrolling to the middle of the iframe shows the second box and // hides the first. var io = new IntersectionObserver(spy); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 202); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 0); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles iframe changes', function(done) { var spy = sinon.spy(); // Iframe goes off screen and returns. var io = new IntersectionObserver(spy); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].rootBounds.height).to.be(innerHeight); expect(records[0].intersectionRect.height).to.be(200); done(); }, ASYNC_TIMEOUT); }, function(done) { // Completely off screen. iframe.style.top = '-202px'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].rootBounds.height).to.be(innerHeight); expect(records[0].intersectionRect.height).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { // Partially returns. iframe.style.top = '-100px'; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be.within(0.45, 0.55); expect(records[0].isIntersecting).to.be(true); // Top-level bounds. expect(records[0].rootBounds.height).to.be(innerHeight); expect(records[0].intersectionRect.height / 200).to.be.within(0.45, 0.55); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('continues to monitor until the last target unobserved', function(done) { var spy = sinon.spy(); // Iframe goes off screen and returns. var io = new IntersectionObserver(spy); io.observe(target1); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); expect(spy.lastCall.args[0].length).to.be(3); // Unobserve one from the main context and one from iframe. io.unobserve(target1); io.unobserve(iframeTargetEl2); done(); }, ASYNC_TIMEOUT); }, function(done) { // Completely off screen. iframe.style.top = '-202px'; setTimeout(function() { expect(spy.callCount).to.be(2); expect(spy.lastCall.args[0].length).to.be(1); io.unobserve(iframeTargetEl1); done(); }, ASYNC_TIMEOUT); }, function(done) { // Partially returns. iframe.style.top = '-100px'; setTimeout(function() { expect(spy.callCount).to.be(2); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); }); describe('same-origin iframe loaded in an iframe', function() { var ASYNC_TIMEOUT = 300; beforeEach(function(done) { /* Uncomment these lines to force polyfill inside the iframe. delete iframeWin.IntersectionObserver; delete iframeWin.IntersectionObserverEntry; */ // Install polyfill right into the iframe. if (!iframeWin.IntersectionObserver) { var script = iframeDoc.createElement('script'); script.src = 'intersection-observer.js'; script.onload = function() { done(); }; iframeDoc.body.appendChild(script); } else { done(); } }); function computeRectIntersection(rect1, rect2) { var top = Math.max(rect1.top, rect2.top); var bottom = Math.min(rect1.bottom, rect2.bottom); var left = Math.max(rect1.left, rect2.left); var right = Math.min(rect1.right, rect2.right); var width = right - left; var height = bottom - top; return (width >= 0 && height >= 0) && { top: top, bottom: bottom, left: left, right: right, width: width, height: height } || { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; } function checkRootBounds(records) { if (!supportsNativeIntersectionObserver(iframeWin)) { records.forEach(function(record) { expect(rect(record.rootBounds)).to.eql(getRootRect(document)); }); } } function applyParentRect(parentRect) { iframe.style.top = parentRect.top + 'px'; iframe.style.left = parentRect.left + 'px'; iframe.style.height = parentRect.height + 'px'; iframe.style.width = parentRect.width + 'px'; } function createObserver(callback, options, parentRect) { var io = new iframeWin.IntersectionObserver(callback, options); if (parentRect) { applyParentRect(parentRect); } return io; } it('calculates rects for a fully visible frame', function(done) { var parentRect = rect({top: 0, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a fully visible and offset frame', function(done) { var parentRect = rect({top: 10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on top', function(done) { var parentRect = rect({top: -10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, height: 200 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.94, 0.96); // ~0.95 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on bottom', function(done) { var rootRect = getRootRect(document); var parentRect = rect({top: rootRect.bottom - 300 + 10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a fully visible and scrolled frame', function(done) { iframeWin.scrollTo(0, 10); var parentRect = rect({top: 0, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ top: 0, left: 0, width: bodyWidth, // Height is only for the visible area. height: 200 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.94, 0.96); // ~0.95 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on top and scrolled', function(done) { iframeWin.scrollTo(0, 10); var parentRect = rect({top: -10, left: 0, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(4); checkRootBounds(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, // The height is less by both: offset and scroll. height: 200 - 10 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.89, 0.91); // ~0.9 // The target2 is partially visible. var clientRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[3].boundingClientRect)).to.eql(clientRect2); expect(rect(records[3].intersectionRect)).to.eql(intersectRect2); expect(records[3].isIntersecting).to.be(true); expect(records[3].intersectionRatio).to.be.within(0.53, 0.55); // ~0.54 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a fully clipped frame', function(done) { var parentRect = rect({top: -400, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBounds(records); var emptyRect = rect({ top: 0, left: 0, width: 0, height: 0 }); // The documentElement is completely invisible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(emptyRect); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); // The document.body is completely invisible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(emptyRect); expect(records[1].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(0); // The target1 is completely invisible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(emptyRect); expect(records[2].isIntersecting).to.be(false); expect(records[2].intersectionRatio).to.be(0); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('handles style changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // When first element becomes invisible, the second element will show. // And in reverse: when the first element becomes visible again, the // second element will disappear. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = 'none'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = ''; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles scroll changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // Scrolling to the middle of the iframe shows the second box and // hides the first. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 202); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 0); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles parent rect changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // Iframe goes off screen and returns. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); checkRootBounds(records); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].intersectionRect.height).to.be(200); done(); }, ASYNC_TIMEOUT); }, function(done) { // Completely off screen. applyParentRect(rect({top: -202, left: 0, height: 200, width: 100})); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); checkRootBounds(records); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].intersectionRect.height).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { // Partially returns. applyParentRect(rect({top: -100, left: 0, height: 200, width: 100})); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); checkRootBounds(records); expect(records[0].intersectionRatio).to.be.within(0.45, 0.55); expect(records[0].isIntersecting).to.be(true); // Top-level bounds. expect(records[0].intersectionRect.height / 200).to.be.within(0.45, 0.55); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); }); describe('cross-origin iframe', function() { var ASYNC_TIMEOUT = 300; var crossOriginUpdater; beforeEach(function(done) { Object.defineProperty(iframeWin, 'frameElement', {value: null}); /* Uncomment these lines to force polyfill inside the iframe. delete iframeWin.IntersectionObserver; delete iframeWin.IntersectionObserverEntry; */ // Install polyfill right into the iframe. if (!iframeWin.IntersectionObserver) { var script = iframeDoc.createElement('script'); script.src = 'intersection-observer.js'; script.onload = function() { if (iframeWin.IntersectionObserver._setupCrossOriginUpdater) { crossOriginUpdater = iframeWin.IntersectionObserver._setupCrossOriginUpdater(); } done(); }; iframeDoc.body.appendChild(script); } else { done(); } }); afterEach(function() { if (IntersectionObserver._resetCrossOriginUpdater) { IntersectionObserver._resetCrossOriginUpdater(); } }); function computeRectIntersection(rect1, rect2) { var top = Math.max(rect1.top, rect2.top); var bottom = Math.min(rect1.bottom, rect2.bottom); var left = Math.max(rect1.left, rect2.left); var right = Math.min(rect1.right, rect2.right); var width = right - left; var height = bottom - top; return (width >= 0 && height >= 0) && { top: top, bottom: bottom, left: left, right: right, width: width, height: height } || { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; } function checkRootBoundsAreNull(records) { if (!supportsNativeIntersectionObserver(iframeWin)) { records.forEach(function(record) { expect(record.rootBounds).to.be(null); }); } } function applyParentRect(parentRect) { if (crossOriginUpdater) { var parentIntersectionRect = computeRectIntersection( parentRect, getRootRect(document)); crossOriginUpdater(parentRect, parentIntersectionRect); } else { iframe.style.top = parentRect.top + 'px'; iframe.style.left = parentRect.left + 'px'; iframe.style.height = parentRect.height + 'px'; iframe.style.width = parentRect.width + 'px'; } } function createObserver(callback, options, parentRect) { var io = new iframeWin.IntersectionObserver(callback, options); if (parentRect) { applyParentRect(parentRect); } return io; } it('calculates rects for a fully visible frame', function(done) { var parentRect = rect({top: 0, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a fully visible and offset frame', function(done) { var parentRect = rect({top: 10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on top', function(done) { var parentRect = rect({top: -10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, height: 200 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.94, 0.96); // ~0.95 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on bottom', function(done) { var rootRect = getRootRect(document); var parentRect = rect({top: rootRect.bottom - 300 + 10, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(clientRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.be(1); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a fully visible and scrolled frame', function(done) { iframeWin.scrollTo(0, 10); var parentRect = rect({top: 0, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[0].isIntersecting).to.be(true); // 300 / 404 == ~0.743 expect(records[0].intersectionRatio).to.be.within(0.74, 0.75); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 0, left: 0, width: bodyWidth, height: 300 })); expect(records[1].isIntersecting).to.be(true); // 300 / 402 == ~0.746 expect(records[1].intersectionRatio).to.be.within(0.74, 0.75); // The target1 is fully visible. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ top: 0, left: 0, width: bodyWidth, // Height is only for the visible area. height: 200 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.94, 0.96); // ~0.95 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('calculates rects for a clipped frame on top and scrolled', function(done) { iframeWin.scrollTo(0, 10); var parentRect = rect({top: -10, left: 0, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(4); checkRootBoundsAreNull(records); // The documentElement is partially visible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[0].isIntersecting).to.be(true); // (300 - 10) / 404 == ~0.717 expect(records[0].intersectionRatio).to.be.within(0.71, 0.72); // The document.body is partially visible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(rect({ top: 10, left: 0, width: bodyWidth, height: 300 - 10 })); expect(records[1].isIntersecting).to.be(true); // (300 - 10) / 402 == ~0.721 expect(records[1].intersectionRatio).to.be.within(0.72, 0.73); // The target1 is clipped at the top by the iframe's clipping. var clientRect1 = rect({ top: -10, left: 0, width: bodyWidth, height: 200 }); var intersectRect1 = rect({ left: 0, width: bodyWidth, // Top is clipped. top: 10, // The height is less by both: offset and scroll. height: 200 - 10 - 10 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(intersectRect1); expect(records[2].isIntersecting).to.be(true); expect(records[2].intersectionRatio).to.within(0.89, 0.91); // ~0.9 // The target2 is partially visible. var clientRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, height: 200 }); var intersectRect2 = rect({ top: 202 - 10, left: 0, width: bodyWidth, // The bottom is clipped off. bottom: 300 }); expect(rect(records[3].boundingClientRect)).to.eql(clientRect2); expect(rect(records[3].intersectionRect)).to.eql(intersectRect2); expect(records[3].isIntersecting).to.be(true); expect(records[3].intersectionRatio).to.be.within(0.53, 0.55); // ~0.54 done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); }); it('calculates rects for a fully clipped frame', function(done) { var parentRect = rect({top: -400, left: 20, height: 300, width: 100}); var io = createObserver(function(unsortedRecords) { var records = sortRecords(unsortedRecords); expect(records.length).to.be(3); checkRootBoundsAreNull(records); var emptyRect = rect({ top: 0, left: 0, width: 0, height: 0 }); // The documentElement is completely invisible. expect(rect(records[0].boundingClientRect)) .to.eql(rect(documentElement.getBoundingClientRect())); expect(rect(records[0].intersectionRect)).to.eql(emptyRect); expect(records[0].isIntersecting).to.be(false); expect(records[0].intersectionRatio).to.be(0); // The document.body is completely invisible. expect(rect(records[1].boundingClientRect)) .to.eql(rect(body.getBoundingClientRect())); expect(rect(records[1].intersectionRect)).to.eql(emptyRect); expect(records[1].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(0); // The target1 is completely invisible. var clientRect1 = rect({ top: 0, left: 0, width: bodyWidth, height: 200 }); expect(rect(records[2].boundingClientRect)).to.eql(clientRect1); expect(rect(records[2].intersectionRect)).to.eql(emptyRect); expect(records[2].isIntersecting).to.be(false); expect(records[2].intersectionRatio).to.be(0); done(); io.disconnect(); }, {}, parentRect); io.observe(documentElement); io.observe(body); io.observe(iframeTargetEl1); }); it('blocks until crossOriginUpdater is called first time', function(done) { if (supportsNativeIntersectionObserver(iframeWin)) { // Skip: not possible to emulate with the native observer. done(); return; } var spy = sinon.spy(); var parentRect = rect({top: 0, left: 20, height: 300, width: 100}); var io = createObserver(spy, {}); io.observe(iframeTargetEl1); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(0); // Issue the first update. crossOriginUpdater(parentRect, null); done(); }, ASYNC_TIMEOUT); }, function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('doesn\'t block with a root specified', function(done) { var spy = sinon.spy(); var io = createObserver(spy, {root: body}); io.observe(iframeTargetEl1); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var record = sortRecords(spy.lastCall.args[0])[0]; expect(record.intersectionRatio).to.be(1); expect(record.isIntersecting).to.be(true); expect(rect(record.rootBounds)).to.eql(rect(body.getBoundingClientRect())); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles style changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // When first element becomes invisible, the second element will show. // And in reverse: when the first element becomes visible again, the // second element will disappear. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = 'none'; setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeTargetEl1.style.display = ''; setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles scroll changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // Scrolling to the middle of the iframe shows the second box and // hides the first. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 202); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); expect(records[1].intersectionRatio).to.be(1); expect(records[1].isIntersecting).to.be(true); done(); }, ASYNC_TIMEOUT); }, function(done) { iframeWin.scrollTo(0, 0); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles parent rect changes', function(done) { var spy = sinon.spy(); var parentRect = rect({top: 0, left: 0, height: 200, width: 100}); // Iframe goes off screen and returns. var io = createObserver(spy, {}, parentRect); io.observe(iframeTargetEl1); io.observe(iframeTargetEl2); runSequence([ function(done) { setTimeout(function() { expect(spy.callCount).to.be(1); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(2); checkRootBoundsAreNull(records); expect(records[0].intersectionRatio).to.be(1); expect(records[0].isIntersecting).to.be(true); expect(records[1].intersectionRatio).to.be(0); expect(records[1].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].intersectionRect.height).to.be(200); done(); }, ASYNC_TIMEOUT); }, function(done) { // Completely off screen. applyParentRect(rect({top: -202, left: 0, height: 200, width: 100})); setTimeout(function() { expect(spy.callCount).to.be(2); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); checkRootBoundsAreNull(records); expect(records[0].intersectionRatio).to.be(0); expect(records[0].isIntersecting).to.be(false); // Top-level bounds. expect(records[0].intersectionRect.height).to.be(0); done(); }, ASYNC_TIMEOUT); }, function(done) { // Partially returns. applyParentRect(rect({top: -100, left: 0, height: 200, width: 100})); setTimeout(function() { expect(spy.callCount).to.be(3); var records = sortRecords(spy.lastCall.args[0]); expect(records.length).to.be(1); checkRootBoundsAreNull(records); expect(records[0].intersectionRatio).to.be.within(0.45, 0.55); expect(records[0].isIntersecting).to.be(true); // Top-level bounds. expect(records[0].intersectionRect.height / 200).to.be.within(0.45, 0.55); done(); }, ASYNC_TIMEOUT); }, function(done) { io.disconnect(); done(); } ], done); }); it('handles tracking iframe viewport', function(done) { iframe.style.height = '100px'; iframe.style.top = '100px'; iframeWin.scrollTo(0, 110); // {root:iframeDoc} means to track the iframe viewport. var io = createObserver( function (records) { io.unobserve(iframeTargetEl1); var intersectionRect = rect({ top: 0, // if root=null, then this would be 100. left: 0, height: 90, width: bodyWidth }); expect(records.length).to.be(1); expect(rect(records[0].rootBounds)).to.eql(getRootRect(iframeDoc)); expect(rect(records[0].intersectionRect)).to.eql(intersectionRect); done(); }, { root: iframeDoc } ); io.observe(iframeTargetEl1); }); it('handles tracking iframe viewport with rootMargin', function(done) { iframe.style.height = '100px'; var io = createObserver( function (records) { io.unobserve(iframeTargetEl1); var intersectionRect = rect({ top: 0, // if root=null, then this would be 100. left: 0, height: 200, width: bodyWidth }); // rootMargin: 100% --> 3x width + 3x height. var expectedRootBounds = rect({ top: -100, left: -bodyWidth, width: bodyWidth * 3, height: 100 * 3 }); expect(records.length).to.be(1); expect(rect(records[0].rootBounds)).to.eql(expectedRootBounds); expect(rect(records[0].intersectionRect)).to.eql(intersectionRect); done(); }, { root: iframeDoc, rootMargin: '100%' } ); io.observe(iframeTargetEl1); }); }); }); }); /** * Runs a sequence of function and when finished invokes the done callback. * Each function in the sequence is invoked with its own done function and * it should call that function once it's complete. * @param {Array} functions An array of async functions. * @param {Function} done A final callback to be invoked once all function * have run. */ function runSequence(functions, done) { var next = functions.shift(); if (next) { next(function() { runSequence(functions, done); }); } else { done && done(); } } /** * Returns whether or not the current browser has native support for * IntersectionObserver. * @param {Window=} win * @return {boolean} True if native support is detected. */ function supportsNativeIntersectionObserver(win) { win = win || window; return 'IntersectionObserver' in win && win.IntersectionObserver.toString().indexOf('[native code]') > -1; } /** * Sorts an array of records alphebetically by ascending ID. Since the current * native implementation doesn't sort change entries by `observe` order, we do * that ourselves for the non-polyfill case. Since all tests call observe * on targets in sequential order, this should always match. * https://crbug.com/613679 * @param {Array} entries The entries to sort. * @return {Array} The sorted array. */ function sortRecords(entries) { if (supportsNativeIntersectionObserver()) { entries = entries.sort(function(a, b) { return a.target.id < b.target.id ? -1 : 1; }); } return entries; } /** * Adds the common styles used by all tests to the page. */ function addStyles() { var styles = document.createElement('style'); styles.id = 'styles'; document.documentElement.appendChild(styles); var cssText = '#root {' + ' position: relative;' + ' width: 400px;' + ' height: 200px;' + ' background: #eee' + '}' + '#grand-parent {' + ' position: relative;' + ' width: 200px;' + ' height: 200px;' + '}' + '#parent {' + ' position: absolute;' + ' top: 0px;' + ' left: 200px;' + ' overflow: hidden;' + ' width: 200px;' + ' height: 200px;' + ' background: #ddd;' + '}' + '#target1, #target2, #target3, #target4, #target5 {' + ' position: absolute;' + ' top: 0px;' + ' left: 0px;' + ' width: 20px;' + ' height: 20px;' + ' transform: translateX(0px) translateY(0px);' + ' transition: transform .5s;' + ' background: #f00;' + '}'; // IE8 doesn't allow setting innerHTML on a