<div id="horizontal" class="flex items-center justify-center font-sans">
<div class="bg-white rounded shadow p-6 m-4 max-w-lg">
<h1 class="titlecase font-bold">horizontal infinity scrolling </h1>
<i>Page Index: <b id="pageIndex"></b> </i>
<i hidden id="loader">Loading...</i>
<div id="todo-list" class="overflow-auto flex">
<p id="todo-placeholder" class="p-1 m-1 border" hidden></p>
</div>
</div>
</div>
const {
BehaviorSubject,
Observable,
Subject,
debounceTime,
exhaustMap,
filter,
finalize,
fromEvent,
startWith,
takeUntil,
tap,
from,
pipe,
ObservableInput
} = rxjs;
type InfinityScrollDirection = "horizontal" | "vertical";
export interface InfinityScrollOptions {
/**
* The element that is scrollable.
*/
element: HTMLElement;
/**
* A BehaviorSubject that emits true when loading and false when not loading.
*/
loading: BehaviorSubject<boolean>;
/**
* Indicates how far from the end of the scrollable element the user must be before the loadFn is called.
*/
threshold: number;
/**
* The initial page index to start loading from.
*/
initialPageIndex: number;
/**
* The direction of the scrollable element.
*/
scrollDirection?: InfinityScrollDirection;
/**
* The function that is called when the user scrolls to the end of the scrollable element with respect to the threshold.
*/
loadFn: (result: InfinityScrollResult) => Observable<any>;
}
export interface InfinityScrollResult {
/**
* The next page index.
*/
pageIndex: number;
/**
* A Subject that can be used to indicate that more data should be loaded.
*
* Calling `complete()` on this Subject will stop loading more data.
*/
loadMore: Subject<void>;
}
function infinityScroll(options: InfinityScrollOptions) {
const ensureScrolled = pipe(
filter(() => !options.loading.value), // ignore scroll event if already loading
debounceTime(100), // debounce scroll event to prevent lagginess on heavy scroll pages
// filter(() => isScrollable(options.element, options.scrollDirection)),
filter(() => {
const remainingDistance = calculateRemainingDistance(
options.element,
options.scrollDirection
);
return remainingDistance <= options.threshold;
})
);
const fetchData = pipe(
exhaustMap((_, index) => {
options.loading.next(true);
return options.loadFn({
pageIndex: options.initialPageIndex + index
});
}),
tap(() => options.loading.next(false)),
// stop loading if error or explicitly completed (no more data)
finalize(() => options.loading.next(false))
);
return fromEvent(options.element, "scroll").pipe(
startWith(null),
ensureScrolled,
fetchData
);
}
function calculateRemainingDistanceToBottom(element: HTMLElement) {
const scrollPosition = element.scrollTop;
const clientHeight = element.clientHeight;
const totalHeight = element.scrollHeight;
return totalHeight - (scrollPosition + clientHeight);
}
function calculateRemainingDistanceOnXAxis(element: HTMLElement): number {
const scrollPosition = Math.abs(element.scrollLeft);
const clientWidth = element.clientWidth;
const totalWidth = element.scrollWidth;
return totalWidth - (scrollPosition + clientWidth);
}
function calculateRemainingDistance(
element: HTMLElement,
direction: InfinityScrollDirection = "vertical"
) {
if (direction === "horizontal") {
return calculateRemainingDistanceOnXAxis(element);
} else {
return calculateRemainingDistanceToBottom(element);
}
}
const loadingIndicator$ = new BehaviorSubject(false);
// Vertical Infinity Scrolling
const elements = getElements();
const infinityScroll$ = infinityScroll({
initialPageIndex: 1,
threshold: 50,
element: elements.listEl,
loading: loadingIndicator$,
scrollDirection:"horizontal",
loadFn: (result) => {
elements.pageIndexEl.textContent = result.pageIndex;
const dataEndpoint = `https://jsonplaceholder.typicode.com/todos?_start${result.pageIndex}&_limit=10`;
return fetch(dataEndpoint).then((res) => res.json());
}
});
infinityScroll$.subscribe((data) => {
data.forEach((todo) => appendTodoEl(elements, todo));
});
loadingIndicator$.subscribe((loading) => (elements.loaderEl.hidden = !loading));
interface Todo {
title: string;
}
// Utility function to get element from DOM
function getElements() {
const mainId = "horizontal";
const listEl = document.querySelector(`#${mainId} #todo-list`);
const placeholderEl = document.querySelector(`#${mainId} #todo-placeholder`);
const pageIndexEl = document.querySelector(`#${mainId} #pageIndex`);
const loaderEl = document.querySelector(`#${mainId} #loader`);
return {
listEl,
placeholderEl,
pageIndexEl,
loaderEl
};
}
// Utility function to create Todo element
function appendTodoEl(elements: Record<string, HTMLElement>, todo: Todo) {
const newEl = elements.placeholderEl.cloneNode();
newEl.hidden = false;
newEl.textContent = todo.title;
elements.listEl.appendChild(newEl);
}
View Compiled