From ed1c4830a2072bd8b654a86d1179a3912d2fc45d Mon Sep 17 00:00:00 2001 From: ve3 Date: Tue, 23 Feb 2021 18:41:32 +0700 Subject: [PATCH] First commit. --- .gitattributes | 53 +++ LICENSE | 21 ++ package.json | 27 ++ readme.md | 62 ++++ src/RundizScrollPagination.js | 473 ++++++++++++++++++++++++ tests/via-http/assets/css/styles.css | 16 + tests/via-http/pagination-dummydata.php | 22 ++ tests/via-http/test01.html | 57 +++ 8 files changed, 731 insertions(+) create mode 100644 .gitattributes create mode 100644 LICENSE create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/RundizScrollPagination.js create mode 100644 tests/via-http/assets/css/styles.css create mode 100644 tests/via-http/pagination-dummydata.php create mode 100644 tests/via-http/test01.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4346feb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,53 @@ +# Line ending normalization. ------------------------- +# Reference https://help.github.com/articles/dealing-with-line-endings/ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout. +*.inc text +*.ini text +*.txt text +*.xml text + +# Declare files that will always have LF line endings on checkout. +.htaccess text eol=lf +*.css text eol=lf +*.htm text eol=lf +*.html text eol=lf +*.js text eol=lf +*.json text eol=lf +*.map text eol=lf +*.md text eol=lf +*.mo text eol=lf +*.php text eol=lf +*.po text eol=lf +*.pot text eol=lf +*.sql text eol=lf +*.svg text eol=lf +*.yml text eol=lf + +# Denote all files that are truly binary and should not be modified. +*.eot binary +*.gif binary +*.ico binary +*.jpeg binary +*.jpg binary +*.mp3 binary +*.mp4 binary +*.otf binary +*.png binary +*.swf binary +*.ttf binary +*.wav binary +*.woff binary +*.woff2 binary +# End line ending normalization. ---------------------- + +# Export ignore folders, files. +.dev-notes export-ignore +.gitattributes export-ignore +.gitignore export-ignore +node_modules export-ignore +gulpfile.js export-ignore +package.json export-ignore +package-lock.json export-ignore \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb48fcf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Rundiz.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..2045e20 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "rundizscrollpagination", + "version": "0.0.1", + "description": "Scroll down the page and automatically call to get next page contents.", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Rundiz/RundizScrollPagination.git" + }, + "keywords": [ + "scroll", + "pagination", + "infinite" + ], + "author": "Vee W.", + "license": "MIT", + "bugs": { + "url": "https://github.com/Rundiz/RundizScrollPagination/issues" + }, + "homepage": "https://github.com/Rundiz/RundizScrollPagination#readme" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5422b96 --- /dev/null +++ b/readme.md @@ -0,0 +1,62 @@ +# Rundiz Scroll Pagination + +Scroll down the page and automatically call to get next page contents. + +## Features +* Scroll down and auto make AJAX call to get next page contents. +* Can replace current URL in case you hit back or reload and it will be continue from current start offset. (Can be disable via option.) +* There are events to use for custom render HTML elements. +* Hide all children elements that are outside of visible area to improve performance. + +### Example: +```html +
+ + + +``` + +See more options inside class `constructor()`. \ No newline at end of file diff --git a/src/RundizScrollPagination.js b/src/RundizScrollPagination.js new file mode 100644 index 0000000..f91936b --- /dev/null +++ b/src/RundizScrollPagination.js @@ -0,0 +1,473 @@ +/** + * Rundiz Scroll pagination. + * + * @author Vee W. + * @license MIT. + * @version 0.0.1 + */ + + +/** + * Rundiz Scroll pagination (infinite scroll). + */ +class RundizScrollPagination { + + + /** + * Class constructor. + * + * @param {object} option + */ + constructor(option = {}) { + if (!option.containerSelector) { + option.containerSelector = '#container'; + } + this.containerSelector = option.containerSelector; + + if (!option.childrenItemSelector) { + option.childrenItemSelector = '.each-item'; + } + this.childrenItemSelector = option.childrenItemSelector; + + if (isNaN(option.bottomOffset) || isNaN(parseFloat(option.bottomOffset))) { + // the bottom offset where scroll almost to very bottom of page. + option.bottomOffset = 30; + } + this.bottomOffset = option.bottomOffset; + + if (isNaN(option.offsetHideShow) || isNaN(parseFloat(option.offsetHideShow))) { + // the offset that children item will be hide or show when outside visible area. + option.offsetHideShow = 40; + } + this.offsetHideShow = option.offsetHideShow; + + // change url options ---------------------------- + if (option.changeURL !== true && option.changeURL !== false) { + option.changeURL = true; + } + this.changeURL = option.changeURL; + + if (!option.changeURLParamStartOffset) { + // querystring for start offset to push to the URL. + // example ?rdspStartOffset=10 when scroll to next page from first while displaying 10 items per page. + option.changeURLParamStartOffset = 'rdspStartOffset'; + } + this.changeURLParamStartOffset = option.changeURLParamStartOffset; + // end change url options ------------------------- + + // ajax options ----------------------------------- + if (!option.ajaxUrl) { + // set ajax url with `%startoffset%` to use as start offset. + // example: http://domain.tld/page?offset=%startoffset% + throw new Error('The `ajaxUrl` property is missing.'); + } + this.ajaxUrl = option.ajaxUrl; + + if (!option.ajaxMethod) { + option.ajaxMethod = 'GET'; + } + this.ajaxMethod = option.ajaxMethod; + + if (!option.ajaxData) { + // the ajax data to send with some methods such as POST, PATCH, PULL, DELETE, etc. + // the data will be like name=value&name2=value2 or get the data from the `FormData()` object. + // the string that contain `%startoffset%` will be replace with start offset. + option.ajaxData = ''; + } + this.ajaxData = option.ajaxData; + + if (!option.ajaxAccept) { + option.ajaxAccept = 'application/json'; + } + this.ajaxAccept = option.ajaxAccept; + + // response type for accept. possible values: + // text/html -> response + // application/xml -> responseXML + // application/json -> responseText + // text/plain -> responseText + // application/javascript, application/xxxscript -> responseText + if (!option.ajaxResponseAcceptType) { + option.ajaxResponseAcceptType = 'responseText'; + } + this.ajaxResponseAcceptType = option.ajaxResponseAcceptType; + + if (!option.ajaxContentType) { + option.ajaxContentType = 'application/x-www-form-urlencoded;charset=UTF-8'; + } + this.ajaxContentType = option.ajaxContentType; + + if (!option.ajaxDataSrc) { + // set data source for count how many items retrieved for set new start offset. + option.ajaxDataSrc = 'items'; + } + this.ajaxDataSrc = option.ajaxDataSrc; + // end ajax options ------------------------------- + + this.currentStartOffset = this.detectAndSetCurrentStartOffset(); + this.callingXHR = false; + this.isScrolling = '';// up or down. + this.XHR = new Promise((resolve, reject) => {}); + }// constructor + + + /** + * AJAX pagination. + * + * @private This method was called from `checkScrollAndMakeXHR()`. + */ + ajaxPagination() { + let thisClass = this; + + let promiseObj = new Promise((resolve, reject) => { + if (thisClass.callingXHR === true) { + return reject('previous XHR is calling.'); + } + + thisClass.callingXHR = true; + + let XHR = new XMLHttpRequest(); + + XHR.addEventListener('error', (event) => { + thisClass.callingXHR = false; + reject({'response': '', 'status': (event.currentTarget ? event.currentTarget.status : ''), 'event': event}); + }); + XHR.addEventListener('loadstart', (event) => { + let response = (event.currentTarget ? event.currentTarget : event); + document.dispatchEvent( + new CustomEvent( + 'rdScrollPagination.start', {'detail': response} + ) + ); + }); + XHR.addEventListener('loadend', (event) => { + let response = (event.currentTarget ? event.currentTarget[thisClass.ajaxResponseAcceptType] : ''); + if (thisClass.ajaxAccept.toLowerCase().includes('/json')) { + try { + if (response) { + response = JSON.parse(response); + } + } catch (exception) { + console.error(exception.message, response); + } + } + + let headers = XHR.getAllResponseHeaders(); + let headerMap = {}; + if (headers) { + let headersArray = headers.trim().split(/[\r\n]+/); + headersArray.forEach((line) => { + let parts = line.split(': '); + let header = parts.shift(); + let value = parts.join(': '); + headerMap[header] = value; + }); + headersArray = undefined; + } + headers = undefined; + + if (response[thisClass.ajaxDataSrc] && response[thisClass.ajaxDataSrc].length > 0) { + // if there are items after XHR. + // append pagination data element. + thisClass.appendPaginationDataElement(); + // set next start offset. + thisClass.currentStartOffset = parseInt(thisClass.currentStartOffset) + parseInt(response[thisClass.ajaxDataSrc].length); + // mark calling to false to allow next pagination call. + thisClass.callingXHR = false;// move in here to prevent ajax call again when there are no more data. + } + + if (event.currentTarget && event.currentTarget.status >= 100 && event.currentTarget.status < 400) { + resolve({'response': response, 'status': event.currentTarget.status, 'event': event, 'headers': headerMap}); + } else { + reject({'response': response, 'status': event.currentTarget.status, 'event': event, 'headers': headerMap}); + } + }); + + XHR.open(thisClass.ajaxMethod, thisClass.ajaxUrl.replace('%startoffset%', thisClass.currentStartOffset)); + XHR.setRequestHeader('Accept', thisClass.ajaxAccept); + if (thisClass.ajaxContentType) { + XHR.setRequestHeader('Content-Type', thisClass.ajaxContentType); + } + XHR.send(thisClass.ajaxData.replace('%startoffset%', thisClass.currentStartOffset)); + }); + thisClass.XHR = promiseObj; + + return promiseObj; + }// ajaxPagination + + + /** + * Append pagination data element. + * + * @private This method was called from `ajaxPagination()`. + */ + appendPaginationDataElement() { + let containerElement = document.querySelector(this.containerSelector); + if (containerElement) { + containerElement.insertAdjacentHTML( + 'beforeend', + '' + ); + } + }// appendPaginationDataElement + + + /** + * Check scrolling up or down and change current URL. + * + * @private This method was called from `listenOnScroll()`. + * @param object event + */ + checkScrollAndChangeURL(event) { + if (this.changeURL !== true) { + return ; + } + + let thisClass = this; + let paginationDataElements = document.querySelectorAll('.rd-scroll-pagination'); + + if (paginationDataElements) { + paginationDataElements.forEach((item, index) => { + let rect = item.getBoundingClientRect(); + if (rect.top >= 0 && rect.top < 30) { + // if scrolled and top of this pagination data element is on top within range (30 - for example). + // retrieve this start offset from `data-startoffset="n"`. + let thisStartOffset = item.dataset.startoffset; + + // get all querystrings except start offset and re-assign the start offset. + const params = new URLSearchParams(window.location.search); + let paramObj = {}; + for(let paramName of params.keys()) { + if (paramName !== thisClass.changeURLParamStartOffset) { + paramObj[paramName] = params.get(paramName); + } + } + paramObj[thisClass.changeURLParamStartOffset] = thisStartOffset; + + // build querystring + let currentUrlNoQuerystring = window.location.href.split(/[?#]/)[0]; + let queryString = Object.keys(paramObj).map((key) => { + return encodeURIComponent(key) + '=' + encodeURIComponent(paramObj[key]) + }).join('&'); + + // replace current URL. + window.history.replaceState(null, '', currentUrlNoQuerystring + '?' + queryString); + return; + } + }); + } + }// checkScrollAndChangeURL + + + /** + * Check that bottom element is near the display area and make XHR (AJAX). + * + * @private This method was called from `listenOnScroll()`. + * @param object event + */ + checkScrollAndMakeXHR(event) { + let thisClass = this; + let windowHeight = window.innerHeight; + let scrollTop = window.pageYOffset || document.documentElement.scrollTop; + let documentScrollHeight = document.documentElement.scrollHeight; + let totalScroll = (parseInt(windowHeight) + parseInt(scrollTop)) + parseInt(this.bottomOffset); + + if (totalScroll >= documentScrollHeight) { + /*console.log( + 'total scroll >= document scroll height.', + { + 'totalScroll': totalScroll, + 'documentScrollHeight': documentScrollHeight + } + );*/ + + // begins ajax pagination. + this.ajaxPagination() + .then((responseObject) => { + document.dispatchEvent( + new CustomEvent( + 'rdScrollPagination.done', {'detail': responseObject} + ) + ); + // trigger on scroll for in case that there are space left in the bottom of visible area. + // trigger will try to load next ajax page if there are more space left. + thisClass.triggerOnScroll(); + + return Promise.resolve(responseObject); + }) + .catch((responseObject) => { + // .catch() must be after .then(). see https://stackoverflow.com/a/42028776/128761 + document.dispatchEvent( + new CustomEvent( + 'rdScrollPagination.fail', {'detail': responseObject} + ) + ); + + return Promise.reject(responseObject) + .then(() => { + // not called. + }, (responseObject) => { + // prevent uncaught error. + }); + }); + } + }// checkScrollAndMakeXHR + + + /** + * Check the sub items inside container and hide those are outside of display area on both top and bottom. + * + * This is for performance improvement. + * + * @link https://stackoverflow.com/a/12613687/128761 The ideas. + * @private This method was called from `listenOnScroll()`. + * @param object event + */ + checkScrollOutOfDisplayAreaAndHide(event) { + let containerElement = document.querySelector(this.containerSelector); + if (!containerElement) { + return ; + } + let thisClass = this; + const windowHeight = window.innerHeight; + + // check that all visible elements is outside the display area. + let allVisibleElements = containerElement.querySelectorAll(this.childrenItemSelector + ':not(.rd-scroll-pagination-hidden-child)'); + if (allVisibleElements) { + allVisibleElements.forEach((item, index) => { + let rect = item.getBoundingClientRect(); + if ( + rect.top < -parseInt(thisClass.offsetHideShow) || + rect.bottom > (parseInt(windowHeight) + parseInt(thisClass.offsetHideShow)) + ) { + // if top of this element is outside or far more outside the top of visible area + // OR bottom of this element is outside or far more outside the bottom of visible area + // set height of this item. + item.style.height = rect.height + 'px'; + // set class. + item.classList.add('rd-scroll-pagination-hidden-child'); + // hide children of this item. + if (typeof(item.children[0]) === 'undefined' || !item.children[0]) { + throw new Error('Each item inside the container must contain one child to be able to hide properly.'); + } + item.children[0].hidden = true; + } + }); + } + allVisibleElements = undefined;// clear; + + // check that all invisible elements is inside the display area. + let allInvisibleElements = containerElement.querySelectorAll(this.childrenItemSelector + '.rd-scroll-pagination-hidden-child'); + if (allInvisibleElements) { + allInvisibleElements.forEach((item, index) => { + let rect = item.getBoundingClientRect(); + if ( + rect.top > -parseInt(thisClass.offsetHideShow) && + rect.bottom < (parseInt(windowHeight) + parseInt(thisClass.offsetHideShow)) + ) { + // if top of this element is inside or nearly inside the top of visible area + // AND bottom of this element is inside or nearly inside the bottom of visible area + // unhide children of this item. + item.children[0].hidden = false; + // remove class. + item.classList.remove('rd-scroll-pagination-hidden-child'); + // remove height of this item. + item.style.height = null; + } + }); + } + allInvisibleElements = undefined;// clear; + }// checkScrollOutOfDisplayAreaAndHide + + + /** + * Detect and set current start offset from querystring. + * + * @private This method was called from `constructor()`. + * @return int Return detected number of start offset. + */ + detectAndSetCurrentStartOffset() { + const params = new URLSearchParams(window.location.search); + let currentStartOffsetQuerystring = params.get(this.changeURLParamStartOffset); + + if ( + currentStartOffsetQuerystring === null || + currentStartOffsetQuerystring === '' || + isNaN(currentStartOffsetQuerystring) || + isNaN(parseFloat(currentStartOffsetQuerystring)) || + currentStartOffsetQuerystring < 0 + ) { + currentStartOffsetQuerystring = 0; + } + + return parseInt(currentStartOffsetQuerystring); + }// detectAndSetCurrentStartOffset + + + /** + * Get XHR property object. + * + * @return XMLHttpRequest + */ + async getXHR() { + return this.XHR; + }// getXHR + + + /** + * Invoke, run the class. + */ + invoke() { + this.listenOnScroll(); + }// invoke + + + /** + * Listen on scroll window/element. + * + * @private This method was called from `invoke()`. + */ + listenOnScroll() { + let thisClass = this; + let lastScroll = 0; + + window.addEventListener('scroll', (event) => { + let scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + if (scrollTop >= lastScroll) { + // if scrolling down (content move up but mouse wheel scroll down). + thisClass.isScrolling = 'down'; + thisClass.checkScrollAndMakeXHR(event); + thisClass.checkScrollAndChangeURL(event); + thisClass.checkScrollOutOfDisplayAreaAndHide(event); + } else { + // if scrolling up (content move down but mouse wheel scroll up). + thisClass.isScrolling = 'up'; + thisClass.checkScrollAndChangeURL(event); + thisClass.checkScrollOutOfDisplayAreaAndHide(event); + } + + lastScroll = (scrollTop <= 0 ? 0 : parseInt(scrollTop)); + }, false); + + // trigger on scroll to make ajax pagination work immediately on start if there are space left on the bottom of visible area. + this.triggerOnScroll(); + }// listenOnScroll + + + /** + * Trigger scroll event, + * best on initialize the class to trigger event + * and make ajax call while next pagination element is near the display area. + * + * @private This method was called from `listenOnScroll()`. + */ + triggerOnScroll() { + window.dispatchEvent(new Event('scroll')); + }// triggerOnScroll + + +}// ScrollPagination \ No newline at end of file diff --git a/tests/via-http/assets/css/styles.css b/tests/via-http/assets/css/styles.css new file mode 100644 index 0000000..82b755f --- /dev/null +++ b/tests/via-http/assets/css/styles.css @@ -0,0 +1,16 @@ + + +* { + box-sizing: border-box; +} + + +.posts-container { + border: 1px dotted #ccc; + display: block; + margin: 20px auto; + /*max-height: 90vh;*/ + /*overflow-y: auto;*/ + padding: 10px; + width: 90vw; +} \ No newline at end of file diff --git a/tests/via-http/pagination-dummydata.php b/tests/via-http/pagination-dummydata.php new file mode 100644 index 0000000..fd82214 --- /dev/null +++ b/tests/via-http/pagination-dummydata.php @@ -0,0 +1,22 @@ + + + + + JS scroll pagination + + + + +
+ + + + + \ No newline at end of file