/** * 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: rootEl}); expect(io.root).to.be(rootEl); }); it('throws when root is not an 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('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 = '' + '