← back to Dw Theme Compact Toolbar

assets/dw-native-infinite.js

138 lines

/* DW Native Infinite Scroll — progressive enhancement for theme {% paginate %} grids.
 *
 * Converts the theme's native numbered-pager Liquid grids (collection-list /
 * blog / blog-masonry) to append-on-scroll, WITHOUT touching the Boost AI
 * Search & Discovery surfaces (collection/search product grids — those are
 * handled by Boost's own global infinite_scroll + assets/boost-sd-custom.js).
 *
 * Markup contract (added by the section .liquid files):
 *   <div data-dw-infinite              ← scroll container wrapper
 *        data-dw-infinite-items=".blog-posts"      ← selector of the element whose
 *                                                     CHILDREN are the cards
 *        data-dw-infinite-next="/blogs/news?page=2" ← next page URL (paginate.next.url)
 *        data-dw-infinite-page="1">
 *     <div class="blog-posts"> … cards … </div>
 *     <ul class="pagination" data-dw-infinite-pager> … numbered links … </ul>  ← noscript fallback
 *   </div>
 *
 * Behavior: when the viewport nears the bottom, fetch the next URL, extract the
 * same items-container from the returned HTML, append its children to the live
 * items-container, then read the NEXT next-url from the fetched page. Stops when
 * there is no further next URL. The numbered <ul.pagination> is hidden once JS
 * takes over (kept in the DOM for no-JS users + SEO crawl).
 */
(function () {
  'use strict';

  function initContainer(root) {
    if (root.__dwInfiniteInit) return;
    root.__dwInfiniteInit = true;

    var itemsSel = root.getAttribute('data-dw-infinite-items');
    var itemsEl = itemsSel ? root.querySelector(itemsSel) : root;
    if (!itemsEl) return;

    var nextUrl = root.getAttribute('data-dw-infinite-next') || '';
    var loading = false;
    var done = !nextUrl;

    // Hide the numbered pager — JS is now driving. Kept in DOM as no-JS fallback.
    var pager = root.querySelector('[data-dw-infinite-pager]');
    if (pager) pager.setAttribute('hidden', 'hidden');

    // Sentinel + loading indicator appended after the items container.
    var sentinel = document.createElement('div');
    sentinel.className = 'dw-infinite-sentinel';
    sentinel.setAttribute('aria-hidden', 'true');
    sentinel.style.cssText = 'min-height:1px;width:100%';
    var spinner = document.createElement('div');
    spinner.className = 'dw-infinite-loading';
    spinner.style.cssText = 'display:none;text-align:center;padding:24px 0;font:600 11px/1 -apple-system,system-ui,sans-serif;letter-spacing:1.5px;text-transform:uppercase;color:#999';
    spinner.textContent = 'Loading more…';
    itemsEl.parentNode.insertBefore(spinner, itemsEl.nextSibling);
    itemsEl.parentNode.insertBefore(sentinel, spinner.nextSibling);

    function appendFrom(html, url) {
      var doc = new DOMParser().parseFromString(html, 'text/html');
      // Find the matching items container on the fetched page.
      var src = null;
      var fetchedRoot = doc.querySelector('[data-dw-infinite][data-dw-infinite-items="' + itemsSel + '"]') ||
                        doc.querySelector('[data-dw-infinite]');
      if (fetchedRoot) src = itemsSel ? fetchedRoot.querySelector(itemsSel) : fetchedRoot;
      if (!src) src = doc.querySelector(itemsSel || '*');
      if (src) {
        var frag = document.createDocumentFragment();
        // Skip masonry sizer elements (they must stay single).
        Array.prototype.slice.call(src.children).forEach(function (child) {
          if (child.hasAttribute && (child.hasAttribute('data-masonry-sizer') ||
              (child.classList && child.classList.contains('masonry-grid-sizer')))) return;
          frag.appendChild(child.cloneNode(true));
        });
        itemsEl.appendChild(frag);
        // Re-layout masonry if the theme's masonry lib is present.
        if (window.dwRelayoutMasonry) { try { window.dwRelayoutMasonry(itemsEl); } catch (e) {} }
        document.dispatchEvent(new CustomEvent('dw:infinite:appended', { detail: { container: itemsEl, url: url } }));
      }
      // Read the next-next url from the fetched root (or its pager).
      var newNext = '';
      if (fetchedRoot) newNext = fetchedRoot.getAttribute('data-dw-infinite-next') || '';
      if (!newNext) {
        var np = doc.querySelector('[data-dw-infinite-pager] .next a, [data-dw-infinite-pager] a[rel="next"]');
        if (np) newNext = np.getAttribute('href') || '';
      }
      return newNext;
    }

    function loadMore() {
      if (loading || done) return;
      loading = true;
      spinner.style.display = 'block';
      fetch(nextUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
        .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
        .then(function (html) {
          nextUrl = appendFrom(html, nextUrl);
          if (!nextUrl) done = true;
        })
        .catch(function () {
          // On any error, fall back to showing the numbered pager again.
          done = true;
          if (pager) pager.removeAttribute('hidden');
        })
        .then(function () {
          loading = false;
          spinner.style.display = 'none';
          // If the new content didn't fill the viewport, keep loading.
          if (!done) maybeLoad();
        });
    }

    function maybeLoad() {
      if (done || loading) return;
      var rect = sentinel.getBoundingClientRect();
      if (rect.top - window.innerHeight < 700) loadMore();
    }

    if ('IntersectionObserver' in window) {
      var io = new IntersectionObserver(function (entries) {
        if (entries.some(function (e) { return e.isIntersecting; })) loadMore();
      }, { rootMargin: '700px 0px' });
      io.observe(sentinel);
    } else {
      window.addEventListener('scroll', maybeLoad, { passive: true });
    }
    // Initial check in case the first page is short.
    maybeLoad();
  }

  function initAll() {
    var roots = document.querySelectorAll('[data-dw-infinite]');
    for (var i = 0; i < roots.length; i++) initContainer(roots[i]);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
  } else {
    initAll();
  }
})();