[object Object]

← back to Dw Material Reclassify

FIX infinite-scroll regression: scope card-hover observer to Boost grid (was document.body self-feedback loop starving Boost lazy-load); verified 24->215 loads

20b2848981d06d1b705c678e8816508f41b3883c · 2026-07-22 13:09:43 -0700 · Steve

Files touched

Diff

commit 20b2848981d06d1b705c678e8816508f41b3883c
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 13:09:43 2026 -0700

    FIX infinite-scroll regression: scope card-hover observer to Boost grid (was document.body self-feedback loop starving Boost lazy-load); verified 24->215 loads
---
 dw-card-hover.js | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 241 insertions(+)

diff --git a/dw-card-hover.js b/dw-card-hover.js
new file mode 100644
index 0000000..99c5d35
--- /dev/null
+++ b/dw-card-hover.js
@@ -0,0 +1,241 @@
+/* ============================================================================
+   DW card label  v6  (2026-07-22) — collection / search product grids.
+   v6 (Steve): cards are now IMAGE-ONLY at rest — the name/SKU label is HIDDEN
+       until hover. On hover (or focus, or always on touch), a light scrim strip
+       fades in over the bottom of the IMAGE showing the pattern name + DW SKU.
+       No layout shift (overlay is absolute over the thumbnail). DW SKU is read
+       from the Boost product-code node when Boost is configured to emit it;
+       until then the SKU line is simply omitted (name still shows).
+   v5: compact always-visible pill UNDER the image (name + vendor).
+   v4: also hides the on-image quick-add / quick-view hover buttons.
+
+   The native Boost text band stays clipped (SEO/a11y in DOM). Works on the
+   theme markup (.product-list-item*) AND Boost AI markup
+   (.boost-sd__product-item), re-applying to lazy / infinite-scroll /
+   re-rendered cards via a MutationObserver.
+
+   Source asset: assets/dw-card-hover.js  (loaded by layout/theme.liquid).
+   Backups: assets/dw-card-hover.js.v5-bak , assets/dw-card-hover.js.v1-bak
+   ========================================================================== */
+(function () {
+  if (window.__dwCardHover) return;
+  window.__dwCardHover = true;
+
+  var CARD   = '.product-list-item, .boost-sd__product-item';
+  var TITLE  = '.product-list-item-title, .boost-sd__product-title';
+  // DW SKU node — Boost emits a product-code/sku node here once configured to
+  // show SKU on the card. Multiple candidate selectors so it lights up whatever
+  // Boost uses without another edit.
+  var SKU    = '.boost-sd__product-sku, [class*="boost-sd__product-sku"], ' +
+               '[class*="product-code"], .product-list-item-sku, [data-sku]';
+  var THUMB  = '.product-list-item-thumbnail, .boost-sd__product-image-wrapper, [class*="boost-sd__product-image"]:not(img)';
+
+  var STYLE_ID = 'dw-card-hover-style';
+  if (!document.getElementById(STYLE_ID)) {
+    var st = document.createElement('style');
+    st.id = STYLE_ID;
+    st.textContent = [
+      /* image wrapper = positioning context for the absolute overlay */
+      '.product-list-item .product-list-item-thumbnail,',
+      '.boost-sd__product-item .boost-sd__product-image-wrapper,',
+      '.boost-sd__product-item [class*="boost-sd__product-image"]:not(img){position:relative;}',
+
+      /* collapse the native text band (name/vendor/price) to zero — kept in DOM
+         for SEO/a11y. Cards are image-only at rest. */
+      '.product-list-item .product-list-item-details,',
+      '.boost-sd__product-item .boost-sd__product-info,',
+      '.boost-sd__product-item .boost-sd__product-title,',
+      '.boost-sd__product-item .boost-sd__product-vendor,',
+      '.boost-sd__product-item [class*="boost-sd__product-price"],',
+      '.boost-sd__product-item [class*="boost-sd__format-price"]{',
+        'position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;',
+        'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;}',
+
+      /* hide the on-image quick-add / quick-view hover buttons */
+      '.boost-sd__product-item .boost-sd__button--show-on-hover,',
+      '.boost-sd__product-item [class*="boost-sd__btn-quick"],',
+      '.boost-sd__product-item [class*="boost-sd__product-action"]{display:none!important;}',
+
+      /* === DW reveal v6: absolute scrim strip over the bottom of the image.
+         Hidden at rest (image-only); fades in on card hover / keyboard focus. === */
+      '.dw-hover-overlay{position:absolute;left:0;right:0;bottom:0;z-index:3;',
+        'box-sizing:border-box;padding:14px 10px 9px;pointer-events:none;',
+        'opacity:0;transition:opacity .16s ease;',
+        'background:linear-gradient(to top,rgba(0,0,0,.62) 0%,rgba(0,0,0,.30) 55%,rgba(0,0,0,0) 100%);',
+        'text-align:center;',
+        'font-family:-apple-system,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}',
+      /* reveal triggers */
+      '.product-list-item:hover .dw-hover-overlay,',
+      '.product-list-item:focus-within .dw-hover-overlay,',
+      '.boost-sd__product-item:hover .dw-hover-overlay,',
+      '.boost-sd__product-item:focus-within .dw-hover-overlay{opacity:1;}',
+      /* the pattern name */
+      '.dw-hover-overlay .pat{display:block;max-width:100%;',
+        'font-size:clamp(11px,0.82vw,13px);font-weight:600;line-height:1.2;',
+        'color:#fff;letter-spacing:.01em;text-shadow:0 1px 2px rgba(0,0,0,.5);',
+        'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
+      /* the DW SKU */
+      '.dw-hover-overlay .sku{display:block;font-size:9px;letter-spacing:.12em;',
+        'text-transform:uppercase;color:#e8e2d8;margin-top:2px;line-height:1.2;',
+        'text-shadow:0 1px 2px rgba(0,0,0,.5);}',
+      /* touch / no-hover devices: no hover exists, so show the label always so
+         name + SKU stay reachable on phones. */
+      '@media (hover:none){.dw-hover-overlay{opacity:1;}}',
+      '@media (prefers-reduced-motion:reduce){.dw-hover-overlay{transition:none;}}'
+    ].join('');
+    document.head.appendChild(st);
+  }
+
+  function esc(s) {
+    return s.replace(/[&<>"']/g, function (c) {
+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+    });
+  }
+
+  var STOP = { and:1, or:1, of:1, on:1, in:1, the:1, a:1, an:1, to:1, with:1 };
+  function titleCase(s) {
+    var i = 0;
+    return s.replace(/\w[\w']*/g, function (w) {
+      i++;
+      var lower = w.toLowerCase();
+      if (i > 1 && STOP[lower]) return lower;
+      return w.charAt(0).toUpperCase() + w.slice(1);
+    });
+  }
+
+  function patternName(title) {
+    var t = title;
+    if (t.indexOf('|') !== -1) t = t.split('|')[0];
+    else {
+      var by = t.split(/\s+by\s+/i);
+      if (by.length > 1) t = by[0];
+    }
+    t = t.replace(/\s*wallcoverings?\b/ig, '').replace(/[,\s]+$/, '').trim();
+    return titleCase(t);
+  }
+
+  // Pull a DW SKU from the card if Boost emits one. Accepts a data-sku attr or
+  // the text of a sku/product-code node. Returns '' when absent.
+  function skuOf(card) {
+    var el = card.querySelector(SKU);
+    if (!el) return '';
+    var s = (el.getAttribute && el.getAttribute('data-sku')) || el.textContent || '';
+    s = s.replace(/\s*(sku|item|product code)\s*[:#]?\s*/i, '').replace(/\s+/g, ' ').trim();
+    return s;
+  }
+
+  function labelCard(card) {
+    if (card.querySelector('.dw-hover-overlay')) return;
+    var tEl = card.querySelector(TITLE);
+    if (!tEl) return;
+    var name = patternName(tEl.textContent || '');
+    if (!name) return;
+    var sku = skuOf(card);
+
+    var imgWrap = card.querySelector(THUMB);
+    if (!imgWrap) {
+      var img = card.querySelector('img');
+      imgWrap = img ? img.parentElement : null;
+    }
+    if (!imgWrap) return;
+
+    var ov = document.createElement('div');
+    ov.className = 'dw-hover-overlay';
+    ov.innerHTML = '<span class="pat">' + esc(name) + '</span>' +
+      (sku ? '<span class="sku">' + esc(sku) + '</span>' : '');
+
+    /* v6: overlay lives INSIDE the image wrapper (absolute), so at rest the card
+       is pure image and the reveal never shifts layout. */
+    imgWrap.appendChild(ov);
+  }
+
+  // Initial full-scan: used only at page-load time (not inside the observer).
+  function run() {
+    var cards = document.querySelectorAll(CARD);
+    for (var i = 0; i < cards.length; i++) labelCard(cards[i]);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', run);
+  } else {
+    run();
+  }
+
+  // Selectors for the Boost / theme product-grid container.
+  // We observe this element only — not document.body — so toolbar, sidebar,
+  // price-slider, and pagination mutations are invisible to the observer.
+  var GRID_SEL = '.boost-sd__product-list, [class*="product-list"]:not(.product-list-item)';
+
+  // Card-node predicate: true when el IS a product card or CONTAINS product cards.
+  function nodeHasCards(el) {
+    if (!el || el.nodeType !== 1) return false;
+    if (el.matches && el.matches(CARD)) return true;
+    return !!(el.querySelector && el.querySelector(CARD));
+  }
+
+  // Collect only the card elements that arrived in this mutation batch.
+  // We operate on the batch directly — no full-document querySelectorAll.
+  function labelFromMutations(muts) {
+    for (var m = 0; m < muts.length; m++) {
+      var added = muts[m].addedNodes;
+      for (var n = 0; n < added.length; n++) {
+        var node = added[n];
+        if (!nodeHasCards(node)) continue;
+        // node itself is a card
+        if (node.matches && node.matches(CARD)) {
+          labelCard(node);
+        }
+        // node is a wrapper that contains cards (e.g. Boost appends a <ul> of cards)
+        if (node.querySelectorAll) {
+          var nested = node.querySelectorAll(CARD);
+          for (var c = 0; c < nested.length; c++) labelCard(nested[c]);
+        }
+      }
+    }
+  }
+
+  // Attach observer to the grid container. Fall back to document.body ONLY if
+  // the grid container is not yet in the DOM (e.g. pure-JS Boost build), in
+  // which case we watch for the container to appear, then re-attach.
+  function attachObserver() {
+    var grid = document.querySelector(GRID_SEL);
+    if (grid) {
+      // Scope: grid container only; no subtree needed for card-level childList
+      // changes, but subtree:true is kept so Boost's nested wrappers are covered.
+      // The narrow root means non-grid page mutations never reach the callback.
+      obs.observe(grid, { childList: true, subtree: true });
+    } else {
+      // Grid not present yet — watch body at the top level only (no subtree)
+      // for the grid container itself to be inserted, then re-attach tightly.
+      var bodyObs = new MutationObserver(function (muts) {
+        for (var m = 0; m < muts.length; m++) {
+          var added = muts[m].addedNodes;
+          for (var n = 0; n < added.length; n++) {
+            var node = added[n];
+            if (!node || node.nodeType !== 1) continue;
+            if ((node.matches && node.matches(GRID_SEL)) ||
+                (node.querySelector && node.querySelector(GRID_SEL))) {
+              bodyObs.disconnect();
+              attachObserver(); // re-attach to the now-present grid
+              return;
+            }
+          }
+        }
+      });
+      bodyObs.observe(document.body, { childList: true, subtree: false });
+    }
+  }
+
+  var t;
+  var obs = new MutationObserver(function (muts) {
+    // Debounce: Boost appends cards in a rapid burst. Collect the whole burst
+    // before acting, but only label the cards from that specific batch —
+    // never re-scan the entire document.
+    // We capture muts in a closure so the debounced call works on the batch.
+    var pending = muts.slice(); // snapshot the live MutationRecord list
+    clearTimeout(t);
+    t = setTimeout(function () { labelFromMutations(pending); }, 80);
+  });
+
+  attachObserver();
+})();

← b25bea3 Material template: skip image-less products in the grid  ·  back to Dw Material Reclassify  ·  Material pages restored + native filters (Material/Availabil 6b88e76 →