infinite-scroll-init
Version:
Infinite scroll initiator with loading indicator
601 lines (530 loc) • 30.1 kB
JavaScript
/**
* @version 5.2.0
* @author Mahmoud Al-Refaai <Schuttelaar & Partners>
*/
export default class InfiniteScroll {
/**
* Constructor of the InfiniteScroll object
* @param {Object} config the configuration of this InfiniteScroll instance
* @param {1} [config.segment] the segment number on initiate. Default is the value of segment param in window query-string or `1` if this param doesn't exist.
* @param {"segment"} [config.segmentParam] override the default param name 'segment'
* @param {string} config.container string selector of the content container (eg: "#containerId" or ".containerClass")
* @param {false} [config.lockInfiniteScroll] boolean weather to prevent scroll-event from trigger the fetch function
* @param {true} [config.autoFill] boolean weather to keep fetching data on first load until the page is filled (ie. scrollbar appear)
* @param {false} [config.autoScroll] boolean weather to scroll down to last segment on page load, in case the segment > 1.
* @param {false} [config.fetchOnInitiate] boolean weather to fetch all data till the specified segment on infinite scroll initiate
* @param {number} [config.offset] number in pixels such that fetch is triggered on reaching this offset before the end of the content list
* @param {string} config.dataRoute the url-route to fetch the data from
* @param {'html'|'json'} [config.dataType] the data-type of the response, ['html' | (default) 'json']
* @param {()=>string|JSON} [config.getDataParams] function return the data (query string or object) to be used in fetch request
* @param {(res: string|JSON)=>void} config.onSuccess callback function when fetch request succeed
* @param {(err: Error)=>void} [config.onError] callback function when fetch request failed
* @param {string} [config.noResultsSelector] Selector string of HTML element to show if there are no results at all (ie. no results in the first segment or `fetchOnInitiate`).
* @param {string} [config.noResultsMessage] HTML string for message when no results to show (ie. no results in the first segment or `fetchOnInitiate`).
* @param {(res: string|JSON)=>void} [config.onNoResults] callback function when there is no results at all (ie. no results in the first segment or `fetchOnInitiate`).
*
* @param {(value: number)=>void} [config.updateContentCounter] callback function to update the total number of items. The value is passed directly from `ContentCounter` header.
* @param {(key: string, value: string)=>void} [config.updateParam] callback function to update the segment param in query string
*
* @param {boolean} config.loadingIndicator.active boolean whether to use loading indicator while fetching, default = false
* @param {string} config.loadingIndicator.container string for query selector of container, default is the parent of config.container passed above
* @param {string} config.loadingIndicator.color string indicate the color hash or color name
* @param {string} config.loadingIndicator.size string with number + unit (eg. '20px', '0.7em')
* @param {number} config.loadingIndicator.type type of indicator [0 => custom, 1 => circle spinning dots, 2 => horizontal dots], default set to 1
* @param {string} config.loadingIndicator.html HTML string for custom loading indicator (Note: class of outer div need to be 'inf-loading-indicator'),
* to use this custom indicator the type should be set to 0.
*
* @param {boolean} config.loadMoreIndicator.active boolean whether to use load more indicator when there is more content to fetch, default = false
* @param {string} config.loadMoreIndicator.container string for query selector of container, default is the parent of config.container passed above
* @param {string} config.loadMoreIndicator.color string indicate the color hash or color name
* @param {string} config.loadMoreIndicator.scale integer to specify the scale of the indicator icon, default = 5
* @param {boolean} config.loadMoreIndicator.animated boolean weather to animate load-more indicator, default = true
* @param {function} config.loadMoreIndicator.onHover function that fire on 'mouseover' over load-more-indicator, default = this.autoFill()
* @param {string} config.loadMoreIndicator.html string with HTML of custom load-more indicator (Note: class of outer div need to be 'inf-load-more-indicator'),
* if this not used, the default load-more icon will be used.
*/
constructor(config) {
this.config = {
segment: undefined, //this will be override below
segmentParam: 'segment',
container: '',
lockInfiniteScroll: false,
autoFill: true,
autoScroll: false,
fetchOnInitiate: false,
scrollLsn: true,
offset: document.documentElement.clientHeight / 2,
dataRoute: '',
dataType: 'json',
getDataParams: () => window.location.search.substr(1),
onSuccess: () => {},
onError: () => {},
noResultsSelector: "",
noResultsMessage: document.querySelector(config.container) ? document.querySelector(config.container).dataset.noResultsMessage : '',
onNoResults: () => {},
updateContentCounter: () => {},
updateParam: (key, value) => {
const dataParams = new URLSearchParams(window.location.search);
dataParams.set(key, value);
history.pushState({}, document.title, window.location.href.split('?')[0] +
'?' + decodeURI(dataParams.toString()) + window.location.hash);
},
loadingIndicator: {
active: false,
container: document.querySelector(config.container).parentNode,
color: 'lightgray',
size: '0.7em',
type: 1,
html: '', //if type == 0, this html will be used as custom indicator and no style will be applied.
},
loadMoreIndicator: {
active: false,
container: document.querySelector(config.container).parentNode,
color: 'lightgray',
scale: 5,
html: '',
animated: true,
onHover: () => this.fetch(),
},
};
this.editConfig(config);
//in case this hasn't been override by config argument
if (this.config.segment === undefined) {
// Get segment parameter from query string
const urlParams = new URLSearchParams(window.location.search);
const segmentParam = urlParams.get(this.config.segmentParam);
this.config.segment = segmentParam && !isNaN(segmentParam) ? parseInt(segmentParam) : 1;
}
//store onScroll function in a local variable so it can be used later on removed
this.onScroll = function() {
const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
if (!this.config.lockInfiniteScroll && (scrollTop + clientHeight > scrollHeight - this.config.offset))
this.fetch();
}
this.scrollHandler = this.onScroll.bind(this);
this.abortController = new AbortController();
this.config.scrollLsn && this.addScrollLsn();
this.$container = document.querySelector(this.config.container);
this.config.loadingIndicator.active &&
initLoadingIndicator(this.config.loadingIndicator);
this.$loadingIndicator = document.querySelector('.inf-loading-indicator');
this.config.loadMoreIndicator.active &&
initLoadMoreIndicator(this.config.loadMoreIndicator);
this.$loadMoreIndicator = document.querySelector('.inf-load-more-indicator');
this.noResultsHandler = (res) => {
if (document.querySelector(this.config.container).childElementCount > 0)
return;
if(this.config.noResultsMessage) {
let parser = new DOMParser();
let doc = parser.parseFromString(this.config.noResultsMessage, 'text/html');
document.querySelector(this.config.container).append(doc.body.firstChild);
}
if(this.config.noResultsSelector)
document.querySelector(this.config.noResultsSelector).style.display = '';
this.config.onNoResults(res);
}
// fetch initial data;
// Since fetchOnInitiate is true, the first segment will be cached before rendering in fetch function!
if (this.config.fetchOnInitiate) {
this.fetch().then((moreContent) => {
//scroll down to last segment after initial fetch is done
if (this.config.autoScroll && this.config.segment > 1)
this.scrollDown();
//if autoFill is enabled, keep fetching next segments to fill the page
moreContent && this.config.autoFill && this.autoFill();
}).catch(() => {}); //in case of error (rejected promise), ignore it!
} else {
if (this.config.autoScroll && this.config.segment > 1)
this.scrollDown();
//cache first segment, then proceed to autoFill
this.config.lockInfiniteScroll = true;
this.cacheNextSegment().then((res) => {
if (!res.error) {
this.config.lockInfiniteScroll = false;
this.config.autoFill && this.autoFill();
}
});
}
// Bind all class' functions to "this"
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
methods
.filter(method => (method !== 'constructor'))
.forEach((method) => { this[method] = this[method].bind(this); });
}
editConfig(config) {
for (let key in config)
if (typeof this.config[key] === 'object')
Object.assign(this.config[key], config[key]);
else
this.config[key] = config[key];
}
/**
* Add the scroll event listener to this container
*/
addScrollLsn() {
window.addEventListener('scroll', this.scrollHandler, true);
}
/**
* Remover the scroll listener from this container
*/
removeScrollLsn() {
window.removeEventListener('scroll', this.scrollHandler, true);
}
/**
* Repeat fetching data until the page is filled (ie. scrollbar is shown).
*/
autoFill() {
if (document.body.clientHeight <= document.documentElement.clientHeight + this.config.offset) {
this.fetch()
.then((moreContent) => {
//in case there is still more content to fetch, check again if the page is not filled.
moreContent && this.autoFill();
})
.catch((e) => {
console.log(e);
}); //in case of error (rejected promise), ignore it!
}
}
/**
* Scroll down to the last three element in the container.
*/
scrollDown() {
let container = document.querySelector(this.config.container);
container.children &&
container.children.length > 3 &&
container.children[container.children.length - 3].scrollIntoView({ behavior: "smooth" });
}
/**
* preform fetch request to the given route and with dataParams given in constructor's config
*/
async fetch() {
if (this.config.lockInfiniteScroll) return;
this.config.lockInfiniteScroll = true;
if(this.config.noResultsSelector && document.querySelector(this.config.noResultsSelector))
document.querySelector(this.config.noResultsSelector).style.display = 'none';
if (this.config.loadMoreIndicator.active)
this.$loadMoreIndicator.style.display = 'none';
if (this.config.loadingIndicator.active)
this.$loadingIndicator.style.display = 'inherit';
if (this.config.fetchOnInitiate) {
//cache first segment before rendering
let resFirst = await this.cacheNextSegment();
//check if there are no results
if (!resFirst.data.length && !resFirst.error)
this.noResultsHandler(resFirst);
if (!resFirst.data.length) return;
}
//increase and update segment state
this.config.segment++;
this.config.updateParam(this.config.segmentParam, this.config.segment);
//render cached data
let res = sessionStorage.getItem('infiniteScrollData');
if (this.config.dataType == 'json')
res = JSON.parse(res);
this.config.onSuccess(res);
//check if there are no results, ie. first segment has no results
if(res.length === 0 && this.config.segment < 2)
this.noResultsHandler(res);
//look up and cache the next segment, return weather there is moreContent or not
return this.cacheNextSegment()
.then(res => {
if (res.data.length) {
this.config.lockInfiniteScroll = false;
if (this.config.loadMoreIndicator.active)
this.$loadMoreIndicator.style.display = 'flex';
return true;
} else {
this.config.lockInfiniteScroll = true;
return false;
}
})
}
/**
* look up the next segment and store it in cache (session storage)
* return promise that resolve to object with `data` contains the results, and `error` key contains error object if existed
* eg. `let { data, error } = await cacheNextSegment();`
*/
async cacheNextSegment() {
this.abortController.abort();
this.abortController = new AbortController();
const dataParams = new URLSearchParams(this.config.getDataParams());
//Add initial param if this an initial fetch (on page load)
if (this.config.fetchOnInitiate) {
this.config.fetchOnInitiate = false;
dataParams.set('initial', 1);
// Since this is an initial fetch, we don't need segment to increase,
// so decrease here to cancel out with the increase in response callback.
this.config.segment--;
}
// fetch next segment
dataParams.set(this.config.segmentParam, this.config.segment + 1);
return fetch(this.config.dataRoute + '?' + dataParams.toString(), {
method: 'GET',
headers: {
'Content-Type': this.config.dataType == 'html' ? 'text/html' : 'application/json',
},
signal: this.abortController.signal
})
.then(async(response) => {
if (response.status != 200)
throw new Error(response.status);
let res = {};
if (this.config.dataType == 'html') {
res = await response.text();
res = res.trim();
sessionStorage.setItem('infiniteScrollData', res);
} else if (this.config.dataType == 'json') {
res = await response.json();
sessionStorage.setItem('infiniteScrollData', JSON.stringify(res));
}
if (this.config.loadingIndicator.active)
this.$loadingIndicator.style.display = 'none';
if (response.headers.get('No-Content'))
res = [];
if (response.headers.get('Content-Counter') !== null)
this.config.updateContentCounter(response.headers.get('Content-Counter'))
return { data: res };
})
.catch(err => {
if (err.name === "AbortError")
return { data: [], error: err };
if (this.config.loadingIndicator.active)
this.$loadingIndicator.style.display = 'none';
if (err.message == 404)
this.config.lockInfiniteScroll = true;
this.config.onError(err);
return { data: [], error: err };
});
}
/**
* @param {boolean} lock set to false to unlock the infinite scroll.
*/
setLockInfiniteScroll(lock) {
this.config.lockInfiniteScroll = lock;
}
/**
* @param {int} segmentNo the new segment number to be set on.
*/
setSegment(segmentNo = 1) {
this.config.segment = segmentNo;
}
/**
* Reset the segment to 0, and unlock the infinite scroll.
*/
reset(removeContents = true) {
if (removeContents) {
while (this.$container.firstChild)
this.$container.removeChild(this.$container.firstChild);
if (this.config.loadingIndicator.active)
this.$loadingIndicator.style.display = 'inherit';
if (this.config.loadMoreIndicator.active)
this.$loadMoreIndicator.style.display = 'none';
if(this.config.noResultsSelector)
document.querySelector(this.config.noResultsSelector).style.display = 'none';
}
this.abortController.abort();
this.config.fetchOnInitiate = true;
this.config.lockInfiniteScroll = false;
this.config.segment = 1;
}
}
/**
* Initiate loading indicator through injecting the required HTML with
* the corresponding CSS as a <style> tag to the specified container.
* @param {Bool} config.active boolean whether to use loading indicator while fetching, default = false
* @param {String} config.container string for query selector of container, default is the parent of config.container passed above
* @param {String} config.color string indicate the color hash or color name
* @param {String} config.size string with number + unit (eg. '20px', '0.7em')
* @param {int} config.type type of indicator [0 => custom, 1 => circle spinning dots, 2 => horizontal dots], default set to 1
* @param {String} config.html string with HTML of custom loading indicator (class of outer div need to be 'inf-loading-indicator'), to use this custom indicator the type should be set to 0.
*/
export function initLoadingIndicator(config) {
let { container, color = 'lightgray', size = '0.7em', type, html } = config;
if (typeof container === 'string')
container = document.querySelector(container);
switch (type) {
case 0: //custom indicator, just append html to container, hence no need to override html value
break;
//case 1: same as default
case 2:
html = `<div style="display: flex; justify-content: center; align-items: center">
<div class="inf-loading-indicator"><div></div><div></div><div></div><div></div></div>
<style>
.inf-loading-indicator {
display: inline-block;
position: relative;
width: calc(${size} * 4.7);
height: calc(${size} * 5);
}
.inf-loading-indicator div {
position: absolute;
top: calc(${size} * 2);
width: ${size};
height: ${size};
border-radius: 50%;
background: ${color};
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.inf-loading-indicator div:nth-child(1) {
left: calc(${size} * 0.44);
animation: inf-loading-indicator1 0.6s infinite;
}
.inf-loading-indicator div:nth-child(2) {
left: calc(${size} * 0.44);
animation: inf-loading-indicator2 0.6s infinite;
}
.inf-loading-indicator div:nth-child(3) {
left: calc(${size} * 1.77);
animation: inf-loading-indicator2 0.6s infinite;
}
.inf-loading-indicator div:nth-child(4) {
left: calc(${size} * 3.11);
animation: inf-loading-indicator3 0.6s infinite;
}
@keyframes inf-loading-indicator1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes inf-loading-indicator3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes inf-loading-indicator2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(calc(${size} * 1.33), 0);
}
}
</style>
</div>`;
break;
default:
html = `<div style="display: flex; justify-content: center; align-items: center">
<div class="inf-loading-indicator" style="display: none;"></div>
<style>
.inf-loading-indicator {
color: ${color};
font-size: ${size};
margin: calc(${size} * 10);
width: calc(${size} * 1.428);
height: calc(${size} * 1.428);
border-radius: 50%;
position: relative;
animation: load4 1.3s infinite linear;
transform: translateZ(0);
}
@keyframes load4 {
0%,
100% {
box-shadow: 0 -3em 0 0.2em, 2em -2em 0 0em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 0;
}
12.5% {
box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
}
25% {
box-shadow: 0 -3em 0 -0.5em, 2em -2em 0 0, 3em 0 0 0.2em, 2em 2em 0 0, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
}
37.5% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
}
50% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
}
62.5% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
}
75% {
box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
}
87.5% {
box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
}
}
</style>
</div>`;
}
let parser = new DOMParser();
let doc = parser.parseFromString(html, 'text/html');
container.append(doc.body.firstChild);
}
/**
* Initiate load-more indicator through injecting the required HTML with the corresponding CSS as a <style> tag to the specified container.
* @param {boolean} config.active boolean whether to use load more indicator when there is more content to fetch, default = false
* @param {string} config.container string for query selector of container, default is the parent of config.container passed above
* @param {string} config.color string indicate the color hash or color name
* @param {string} config.scale integer to specify the scale of the indicator icon, default = 5
* @param {boolean} config.animated boolean weather to animate load-more indicator, default = true
* @param {function} config.onHover function that fire on 'mouseover' over load-more-indicator, default = this.autoFill()
* @param {string} config.html string with HTML of custom load-more indicator (Note: class of outer div need to be 'inf-load-more-indicator'),
* if this not used, the default load-more icon will be used. to use this custom indicator the type should be set to 0.
*/
export function initLoadMoreIndicator(config) {
let { container, color = 'lightgray', scale = 5, html, animated = true, onHover = () => {} } = config;
if (typeof container === 'string')
container = document.querySelector(container);
if (!html) {
html = `<div class="inf-load-more-indicator" style="display: flex; justify-content: center; align-items: center; padding: 50px;">
<svg width="${13.415 * scale}" height="${14 * scale}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<polygon id="a" points="12, 0
13.414214, 1.4142136
6.707107, 8.1213204
0, 1.4142136
1.414214, 0
6.707107, 5.2928932"></polygon>
</defs>
<use id="inf-load-more-upper-arrow" x="0" y="0" xlink:href="#a" fill="${color}" transform="scale(${scale})"/>
<use id="inf-load-more-lower-arrow" x="0" y="5" xlink:href="#a" fill="${color}" transform="scale(${scale})"/>
</svg>`;
html += animated ?
`<style>
@keyframes down1 {
from {y: 0;}
to {y: 1.15;}
}
@keyframes down2 {
from {y: 5;}
to {y: 5.6;}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-${scale * 3}px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
to { opacity: 0; transform: translateY(${scale * 10}px); visibility: hidden; }
}
/* The element to apply the animation to */
#inf-load-more-upper-arrow {
animation: down1 1.5s ease-in-out .5s infinite alternate;
}
#inf-load-more-lower-arrow {
animation: down2 1.5s ease-in-out .5s infinite alternate;
}
.inf-load-more-indicator {
animation: fadeIn .5s ease-in 0s;
animation-fill-mode: forwards;
}
</style>` : '';
html += `</div>`;
}
let parser = new DOMParser();
let doc = parser.parseFromString(html, 'text/html');
doc.body.firstChild.addEventListener('mouseover', (e) => {
if (animated) e.currentTarget.style.animation = 'fadeOut .5s ease-out 0s';
setTimeout((el, onHover, animated) => {
if (animated) el.style.animation = 'fadeIn .5s ease 0s';
onHover();
}, 350, e.currentTarget, onHover, animated);
})
container.append(doc.body.firstChild);
}