← back to Commercialrealestate
public/infinite-scroll.js
97 lines
/* infinite-scroll.js — reusable windowed infinite scroll for the CRCP custom-render
* grid pages (deals-flow, contractors, broker-grid, direct-listings) that DON'T go
* through three-view.js (which has its own equivalent windowing built in).
*
* Instead of dumping the whole filtered set into the DOM (which froze the heavy
* pages), reveal BATCH rows and load the next batch as a sentinel scrolls near view.
*
* Usage:
* const inf = InfScroll.attach({
* batch: 120,
* getRows: () => currentFilteredSortedRows, // full array
* getMount: () => document.querySelector(activeContainerSelector), // visible list/grid/table el
* render: (windowRows) => { mount.innerHTML = windowRows.map(...).join(''); },
* });
* // after ANY filter / sort / view change:
* inf.reset(); // resets the window to the first batch and repaints
* inf.shownCount(); // how many are currently revealed (for a "showing N of M" label)
*
* Degrades to "render everything" where IntersectionObserver is unavailable.
* Zero deps. $0, local.
*/
(function (global) {
const IO_OK = typeof IntersectionObserver !== 'undefined';
function attach(O) {
const BATCH = O.batch || 120;
let shown = BATCH;
let last = [];
let sentinel = null, io = null, growing = false;
function ensureSentinel() {
if (!IO_OK) return null;
if (!sentinel) {
sentinel = document.createElement('div');
sentinel.className = 'inf-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
sentinel.style.cssText = 'height:1px;width:100%;';
}
return sentinel;
}
// Find the element that actually scrolls (the mount itself or an ancestor with an
// overflow:auto/scroll AND real overflow). Returns null when the WINDOW scrolls — the
// common case (condos/deals-flow/contractors/direct-listings), where the observer roots
// to the viewport and the sentinel sits AFTER the mount. Inner-scroll panes (broker-grid's
// fixed-height #tableView) return that pane so we root the observer to it and drop the
// sentinel INSIDE it, or window-scroll would never reach the sentinel.
function findScroller(el) {
let n = el;
while (n && n !== document.body && n !== document.documentElement) {
const cs = getComputedStyle(n);
if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
n = n.parentElement;
}
return null;
}
let curRoot = undefined; // the IO root we last built for (undefined = none yet)
function positionSentinel(hasMore) {
const s = ensureSentinel(); if (!s) return;
if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
const mount = O.getMount && O.getMount();
if (!mount || !mount.parentNode) return;
const scroller = findScroller(mount); // null → window scroll
// (re)build the observer if the scroll root changed (view switch can change the pane)
if (scroller !== curRoot) {
if (io) io.disconnect();
io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
curRoot = scroller;
}
if (scroller) {
// inner-scroll pane → sentinel lives at the BOTTOM of the scrolling content
if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
} else {
// window scroll → sentinel sits right after the mount in normal flow
if (mount.nextSibling !== s) mount.parentNode.insertBefore(s, mount.nextSibling);
}
io.observe(s);
}
function paint() {
last = (O.getRows && O.getRows()) || [];
if (shown > last.length) shown = Math.max(BATCH, Math.min(shown, last.length));
const win = IO_OK ? last.slice(0, shown) : last;
if (O.render) O.render(win);
positionSentinel(IO_OK && shown < last.length);
if (O.onPaint) O.onPaint(Math.min(shown, last.length), last.length);
}
function grow() {
if (growing || shown >= last.length) return;
growing = true; shown = Math.min(shown + BATCH, last.length); paint(); growing = false;
}
function reset() { shown = BATCH; paint(); }
return { reset, paint, grow, shownCount: () => Math.min(shown, last.length), total: () => last.length };
}
global.InfScroll = { attach };
})(window);