@odopod/odo-affix
Version:
Makes an element fixed position while its within a container.
405 lines (349 loc) • 10.8 kB
JavaScript
/**
* @fileoverview Emulates `position:sticky` to make an element fixed position
* while its within a container. This is best for sidebars so that they follow
* the content, without overlapping sections below it.
*/
import OdoWindowEvents from '@odopod/odo-window-events';
import OdoScrollAnimation from '@odopod/odo-scroll-animation';
class Affix {
constructor(element) {
/**
* Main element.
* @type {HTMLElement}
*/
this.element = element;
/**
* Parent containing element.
* @type {Element}
*/
this._anchor = document.getElementById(element.getAttribute('data-anchor'));
if (!this._anchor) {
throw new Error(`Unable to find element with id="${element.getAttribute('data-anchor')}"`);
}
/**
* Whether the main element is position fixed.
* @type {boolean}
*/
this.isStuck = false;
/**
* Whether the main element is stuck to the bottom of its container.
* @type {boolean}
*/
this.isAtBottom = false;
/**
* Whether the main element has been promoted to its own layer for the GPU.
* @type {boolean}
* @protected
*/
this.isPromoted = false;
/**
* The amount that the ui overlaps the top of the page. A sticky navigation,
* for example, would cause an overlap equal to its height.
* @type {function():number}
* @private
*/
this._getUiOverlap = () => 0;
/**
* Current UI overlap.
* @type {number}
* @private
*/
this._overlap = 0;
/**
* Current maximum height for the main sticky element.
* @type {number}
* @private
*/
this._maxHeight = 0;
/**
* Main element's top margin.
* @type {number}
* @private
*/
this._marginTop = 0;
/**
* Main element's bottom margin.
* @type {number}
* @private
*/
this._marginBottom = 0;
/**
* Top offset of the main element.
* @type {number}
* @private
*/
this._top = 0;
/**
* Bottom offset of the main element.
* @type {number}
* @private
*/
this._bottom = 0;
/**
* Height of the anchor (container).
* @type {number}
*/
this.containerHeight = 0;
/**
* Unique id for the throttled scroll event listener.
* @type {string}
* @private
*/
this._scrollId = OdoScrollAnimation.add(this.process.bind(this));
this.element.classList.add(Affix.Classes.BASE);
this.element.style.overflowY = 'auto';
// Keep track of instances so they can be batch-processed.
Affix.instances.push(this);
this.update();
}
/**
* Cache values so they don't need to be queried on scroll.
* @protected
*/
read() {
const rect = this._anchor.getBoundingClientRect();
const scrollY = window.pageYOffset;
const viewportHeight = window.innerHeight;
const asideHeight = this.element.offsetHeight;
this._asideWidth = this.element.offsetWidth;
const styles = getComputedStyle(this.element, null);
this._marginTop = parseFloat(styles.marginTop);
this._marginBottom = parseFloat(styles.marginBottom);
this._overlap = this._getUiOverlap();
this._maxHeight = viewportHeight - this._overlap - this._marginTop - this._marginBottom;
this.containerHeight = Math.round(rect.height);
this._top = rect.top + scrollY;
this._bottom = rect.bottom + scrollY - Math.min(asideHeight, this._maxHeight);
}
/** @protected */
write() {
this.element.style.maxHeight = this._maxHeight + 'px';
this.element.style.width = this._asideWidth + 'px';
}
/**
* This method runs on every frame to update the placement of the sticky element.
* @param {number} scrollTop Scroll top of the page.
*/
process(scrollTop = window.pageYOffset) {
// Stick (position fixed).
if ((!this.isStuck && scrollTop >= this.top && scrollTop < this.bottom) ||
(this.isAtBottom && scrollTop < this.bottom)) {
this.stick();
// Affix. Item has reached the end of its view-length, stick it to the bottom.
} else if (!this.isAtBottom && scrollTop >= this.bottom) {
this.stickToBottom();
// Above the position where the sticky element should be position fixed, so unstick it.
} else if (this.isStuck && scrollTop < this.top) {
this.unstick();
}
// When the affix-element's position is soon going to change, promote it
// to a new layer so that the browser does not have to paint it on every scroll.
// Having the affix-element layer promoted all the time is inefficient and greedy.
const isInRange = this.isInPromotionRange(scrollTop);
if (!this.isPromoted && isInRange) {
this.layerPromote();
} else if (this.isPromoted && !isInRange) {
this.layerDemote();
}
}
/**
* Whether the browser's scroll position is within promotion range.
*/
isInPromotionRange(scrollTop) {
return scrollTop >= this.top - Affix.PROMOTION_RANGE &&
scrollTop <= this.bottom + Affix.PROMOTION_RANGE;
}
/** @protected */
stick() {
this.element.style.position = 'fixed';
this.element.style.top = Math.round(this._overlap) + 'px';
this.element.classList.remove(Affix.Classes.AT_BOTTOM);
this.element.classList.remove(Affix.Classes.AT_TOP);
this.isStuck = true;
this.isAtBottom = false;
}
/** @protected */
stickToBottom() {
this.element.style.position = 'absolute';
this.element.style.top = Math.round(this._bottom - this._top - this._marginBottom) + 'px';
this.element.classList.remove(Affix.Classes.AT_TOP);
this.element.classList.add(Affix.Classes.AT_BOTTOM);
this.isAtBottom = true;
}
/** @protected */
unstick() {
this.element.style.position = '';
this.element.classList.add(Affix.Classes.AT_TOP);
this.element.classList.remove(Affix.Classes.AT_BOTTOM);
this.isStuck = false;
this.isAtBottom = false;
}
/**
* Add styles which will put the affix-element in a new layer.
* @protected
*/
layerPromote() {
this.element.style.willChange = 'position';
this.element.style.transform = 'translateZ(0)';
this.isPromoted = true;
}
/**
* Remove styles which cause layer promotion.
* @protected
*/
layerDemote() {
this.element.style.willChange = '';
this.element.style.transform = '';
this.isPromoted = false;
}
/**
* Reset values that are set with `write` so that they can be read again.
* @protected
*/
reset() {
this.element.style.maxHeight = '';
this.element.style.width = '';
}
/**
* TODO(glen): remove getter/setter.
* @return {function():number}
*/
get uiOverlap() {
return this._getUiOverlap;
}
/**
* Define a custom getter to determine overlap.
* @param {function():number} fn
*/
set uiOverlap(fn) {
this._getUiOverlap = fn;
this.update();
}
/**
* The offset when this component becomes sticky.
* @return {number}
*/
get top() {
return this._top - this._overlap;
}
/**
* The offset when this component sticks to the bottom of its container.
* @return {number}
*/
get bottom() {
return this._bottom - this._marginBottom;
}
/**
* Reset everything, cache offsets, and recalculate.
*/
update() {
const { scrollTop } = this.element;
this.unstick();
this.reset();
this.read();
this.write();
this.process();
this.element.scrollTop = scrollTop;
}
/**
* Remove event listeners and references.
*/
dispose() {
this.layerDemote();
this.element.classList.remove(Affix.Classes.BASE);
this.element.style.position = '';
this.element.style.top = '';
this.element.style.maxHeight = '';
this.element.style.width = '';
this.element.style.overflowY = '';
this.element = null;
this._anchor = null;
OdoScrollAnimation.remove(this._scrollId);
Affix.arrayRemove(Affix.instances, this);
}
/**
* Since 'load' events on images do not bubble, the event listener cannot be
* delegated and must be added to every image.
* The load event is not removed once the image loads because the image could
* be a responsive image which could have multiple load events.
*/
static _addImageLoadHandlers() {
const images = document.getElementsByTagName('img');
for (let i = 0, len = images.length; i < len; i++) {
images[i].addEventListener('load', Affix._scheduleUpdate, false);
}
}
/**
* Schedule a throttled update to check if offsets need to be recalculated.
*/
static _scheduleUpdate() {
window.removeEventListener('load', Affix._scheduleUpdate);
// Cancel a previous update if it exists.
if (Affix._updateId) {
window.cancelAnimationFrame(Affix._updateId);
}
// Throttle updates to once per frame.
Affix._updateId = window.requestAnimationFrame(Affix._handleImageLoad);
}
/**
* When an image loads, it could possibly change the layout/geometry of the
* entire page. Because Affix relies on offsets, everything must be
* updated here.
*/
static _handleImageLoad() {
Affix._updateId = null;
Affix.documentHeight = document.body.offsetHeight;
Affix.viewportHeight = window.innerHeight;
Affix.update();
}
/**
* Batch update all instances. This method is more efficient because it syncs
* reads and writes to the DOM for each instance.
*/
static update() {
const scrollY = window.pageYOffset;
const scrollPositions = Affix.instances.map(instance => instance.element.scrollTop);
// Write
Affix.instances.forEach((instance) => {
instance.unstick();
instance.reset();
});
// Read
Affix.instances.forEach((instance) => {
instance.read();
});
// Write
Affix.instances.forEach((instance) => {
instance.write();
instance.process(scrollY);
});
Affix.instances.forEach((instance, i) => {
instance.element.scrollTop = scrollPositions[i];
});
}
/**
* Remove an item from an array.
* @param {Array} arr Array to use.
* @param {*} item Item to remove.
* @return {*} Item removed.
*/
static arrayRemove(arr, item) {
const index = arr.indexOf(item);
arr.splice(index, 1);
return item;
}
}
Affix.PROMOTION_RANGE = 200;
Affix.instances = [];
Affix._updateId = null;
Affix.documentHeight = document.body.offsetHeight;
Affix.viewportHeight = window.innerHeight;
Affix._addImageLoadHandlers();
Affix._resizeId = OdoWindowEvents.onResize(Affix._scheduleUpdate);
window.addEventListener('load', Affix._scheduleUpdate);
Affix.Classes = {
BASE: 'odo-affix',
AT_TOP: 'odo-affix--at-top',
AT_BOTTOM: 'odo-affix--at-bottom',
};
export default Affix;