UNPKG

intersection-observer

Version:
1,420 lines (1,242 loc) 119 kB
/** * 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 = '<style>' + '#slot-parent {' + ' position: relative;' + ' width: 400px;' + ' height: 200px;' + '}' + '</style>' + '<div id="slot-parent"><slot></slot></div>'; 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('<!DOCTYPE html><html><body>'); iframeDoc.write('<style>'); iframeDoc.write('body {margin: 0}'); iframeDoc.write('.target {height: 200px; margin-bottom: 2px; background: blue;}'); iframeDoc.write('</style>'); 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);