← back to Dw Theme Hamburger

assets/dw-hfilter.js

417 lines

/* ============================================================================
   DW Horizontal Filter Bar — JS (Approach B: custom chip bar that proxies
   into Boost's hidden native filter tree)

   Boost AI Search & Discovery renders its filter groups as a vertical sidebar
   (.boost-sd__filter-tree-vertical). We keep that tree in the DOM as the
   authoritative "state engine" (hidden via dw-hfilter.css, gated by
   body.dw-filters-horizontal) and render our OWN horizontal chip bar above the
   grid. On click we PROXY by programmatically clicking the matching hidden
   native option (.boost-sd__filter-option-item-button) — Boost then does the
   real filtering (sets pf_* URL params + re-renders the grid). A
   MutationObserver re-syncs our bar to Boost's authoritative state after every
   re-render. We never touch the URL or own any filter state — Boost does.

   VERIFIED-LIVE Boost DOM (this store):
     - tree container:  .boost-sd__filter-tree-vertical
     - one group:       .boost-sd__filter-block
     - group title:     .boost-sd__filter-option-title
     - option row:      .boost-sd__filter-option-item
     - clickable option:.boost-sd__filter-option-item-button
     - option label:    .boost-sd__filter-option-item-label
     - option count:    .boost-sd__filter-option-item-amount
     - color swatch:    .boost-sd__filter-option-swatch-item-img
     - price slider in: .boost-sd__filter-option-range-slider-input (+ --right)
     - toolbar:         .boost-sd__toolbar-container
     - product grid:    .boost-sd__product-list

   Strict rules:
     - Never cache DOM refs of native options (Boost re-renders constantly);
       store groups by INDEX + normalized option TEXT and re-query on click.
     - Selected/active state is ALWAYS read from Boost's native classes/aria,
       never from our own source of truth.
     - The collapse toggle lives in dw-boost-overrides.liquid (#dw-filter-toggle);
       we only inject a fallback toggle if that pill does not exist.
   ============================================================================ */
(function () {
  'use strict';

  if (!/\/(collections|search)/.test(location.pathname)) return;
  /* Desktop only — on mobile (<=768) Boost shows its own Filter-button drawer and
     lazy-renders options; our horizontal bar is a desktop enhancement. */
  if (window.innerWidth <= 768) return;

  var TREE = '.boost-sd__filter-tree-vertical';
  var GROUP = '.boost-sd__filter-option';
  var TITLE = '.boost-sd__filter-option-title';
  var OPT_BTN = '.boost-sd__filter-option-item-button';
  var OPT_ITEM = '.boost-sd__filter-option-item';
  var OPT_LABEL = '.boost-sd__filter-option-item-label';
  var OPT_AMOUNT = '.boost-sd__filter-option-item-amount';
  var SWATCH_IMG = '.boost-sd__filter-option-swatch-item-img';
  var RANGE_INPUT = '.boost-sd__filter-option-range-slider-input';
  var TOOLBAR = '.boost-sd__toolbar-container';
  var GRID = '.boost-sd__product-list';
  var BAR_ID = 'dw-hfilter-bar';

  /* ---- helpers -------------------------------------------------------- */
  function norm(s) {
    return (s || '')
      .replace(/\s*\(\d[\d,]*\)\s*$/, '') // strip trailing count "(123)"
      .replace(/\s+/g, ' ')
      .trim()
      .toLowerCase();
  }

  /* Read the option's visible label, stripping the trailing count.
     Prefer .boost-sd__filter-option-item-label; else fall back to the button's
     textContent minus the .boost-sd__filter-option-item-amount count. */
  function optLabel(btnEl) {
    var labelEl = btnEl.querySelector(OPT_LABEL);
    if (labelEl) {
      return labelEl.textContent.replace(/\s+/g, ' ').trim();
    }
    var txt = btnEl.textContent || '';
    var amt = btnEl.querySelector(OPT_AMOUNT);
    if (amt && amt.textContent) {
      txt = txt.replace(amt.textContent, '');
    }
    txt = txt.replace(/\s*\(\d[\d,]*\)\s*$/, '').replace(/\s+/g, ' ').trim();
    /* Swatch options (Color) carry their name in title / aria-label, not text. */
    if (!txt) { txt = (btnEl.getAttribute('title') || btnEl.getAttribute('aria-label') || '').trim(); }
    return txt;
  }

  /* Read a swatch background-color from a native option, if any. */
  function optSwatch(btnEl) {
    var sw = btnEl.querySelector(SWATCH_IMG);
    if (!sw) sw = btnEl.querySelector('[style*="background-color"]');
    if (!sw) return null;
    var bg = sw.style && sw.style.backgroundColor;
    if (bg) return bg;
    var m = (sw.getAttribute('style') || '').match(/background-color:\s*([^;]+)/i);
    return m ? m[1].trim() : null;
  }

  /* Is the native option selected? Read Boost's own active class / aria on
     the button or its containing .boost-sd__filter-option-item. */
  function optSelected(btnEl) {
    if (!btnEl) return false;
    var item = btnEl.closest(OPT_ITEM) || btnEl;
    var cls = (btnEl.className || '') + ' ' + (item.className || '');
    if (/--(selected|active|checked)/.test(cls) || /\bselected\b|\bactive\b|\bis-selected\b/.test(cls)) return true;
    if (btnEl.getAttribute('aria-pressed') === 'true') return true;
    if (btnEl.getAttribute('aria-checked') === 'true' || btnEl.getAttribute('aria-selected') === 'true') return true;
    if (item.getAttribute('aria-checked') === 'true' || item.getAttribute('aria-selected') === 'true') return true;
    var inp = btnEl.querySelector('input[type=checkbox],input[type=radio]') ||
              item.querySelector('input[type=checkbox],input[type=radio]');
    if (inp && inp.checked) return true;
    return false;
  }

  function groupTitle(blockEl) {
    var t = blockEl.querySelector(TITLE);
    return t ? t.textContent.replace(/\s+/g, ' ').trim() : '';
  }

  /* Is this block a price/range slider (no discrete option buttons)? */
  function isPriceGroup(blockEl) {
    var buttons = blockEl.querySelectorAll(OPT_BTN);
    if (buttons.length > 0) return false;
    var hasSlider = blockEl.querySelector(RANGE_INPUT) ||
                    blockEl.querySelector('input[type=range], input[type=number]');
    return !!hasSlider;
  }

  /* ---- read the hidden tree into a plain model -------------------------
     Returns: [{ index, title, isPrice, options:[{text, swatch, selected}] }] */
  /* The real group containers, derived from their titles (dedup, ordered).
     The per-group element is .boost-sd__filter-option (one per Color/Style/
     Brand/Durability/Price); .boost-sd__filter-block does NOT repeat per group. */
  function getBlocks() {
    var tree = document.querySelector(TREE);
    if (!tree) return [];
    var titles = tree.querySelectorAll(TITLE), blocks = [];
    for (var i = 0; i < titles.length; i++) {
      var g = titles[i].closest(GROUP);
      if (g && blocks.indexOf(g) === -1) blocks.push(g);
    }
    return blocks;
  }

  function readGroups() {
    var blocks = getBlocks();
    var groups = [];
    for (var i = 0; i < blocks.length; i++) {
      var b = blocks[i];
      var title = groupTitle(b);
      if (!title) continue;
      var price = isPriceGroup(b);
      var options = [];
      if (!price) {
        var btns = b.querySelectorAll(OPT_BTN);
        for (var j = 0; j < btns.length; j++) {
          var label = optLabel(btns[j]);
          if (!label) continue;
          options.push({ text: label, swatch: optSwatch(btns[j]), selected: optSelected(btns[j]) });
        }
        if (!options.length) continue; // empty discrete group — skip
      }
      groups.push({ index: i, title: title, isPrice: price, options: options });
    }
    return groups;
  }

  /* A stable signature of the group set to detect structural changes. */
  function groupsSig(groups) {
    return groups.map(function (g) {
      return g.index + ':' + g.title + ':' + (g.isPrice ? 'P' : g.options.length);
    }).join('|');
  }

  /* ---- proxy a click into the live hidden native tree ------------------ */
  function proxyClick(groupIndex, optText) {
    var blocks = getBlocks();
    var b = blocks[groupIndex];
    if (!b) return false;
    var want = norm(optText);
    var btns = b.querySelectorAll(OPT_BTN);
    for (var i = 0; i < btns.length; i++) {
      if (norm(optLabel(btns[i])) === want) {
        try { btns[i].click(); } catch (e) { return false; }
        return true;
      }
    }
    return false;
  }

  /* ---- render ---------------------------------------------------------- */
  var openChip = null;

  function closePanels() {
    if (openChip) { openChip.classList.remove('dw-hfilter-open'); openChip = null; }
  }

  function buildOpt(group, opt) {
    var row = document.createElement('div');
    row.className = 'dw-hfilter-opt' + (opt.selected ? ' dw-hfilter-opt-selected' : '');

    var check = document.createElement('span');
    check.className = 'dw-hfilter-check';
    check.textContent = opt.selected ? '✓' : '';
    row.appendChild(check);

    if (opt.swatch) {
      var sw = document.createElement('span');
      sw.className = 'dw-hfilter-swatch';
      sw.style.backgroundColor = opt.swatch;
      row.appendChild(sw);
    }

    var txt = document.createElement('span');
    txt.className = 'dw-hfilter-opt-text';
    txt.textContent = opt.text;
    row.appendChild(txt);

    row.addEventListener('click', function (e) {
      e.stopPropagation();
      proxyClick(group.index, opt.text);
      // Boost re-renders; our observer re-syncs. Reflect optimistic state.
      closePanels();
    });
    return row;
  }

  function buildChip(group) {
    var activeCount = group.options.filter(function (o) { return o.selected; }).length;

    var chip = document.createElement('div');
    chip.className = 'dw-hfilter-chip' + (activeCount ? ' dw-hfilter-active' : '');
    chip.setAttribute('data-dw-group', group.index);

    var label = document.createElement('span');
    label.className = 'dw-hfilter-chip-label';
    label.textContent = group.title;
    chip.appendChild(label);

    if (activeCount) {
      var badge = document.createElement('span');
      badge.className = 'dw-hfilter-badge';
      badge.textContent = activeCount;
      chip.appendChild(badge);
    }

    var caret = document.createElement('span');
    caret.className = 'dw-hfilter-caret';
    chip.appendChild(caret);

    var panel = document.createElement('div');
    panel.className = 'dw-hfilter-panel';

    if (group.isPrice) {
      var note = document.createElement('div');
      note.className = 'dw-hfilter-price-note';
      note.textContent = 'Use the price range control in the filters to filter by price.';
      panel.appendChild(note);
      // Price is a native range slider (.boost-sd__filter-option-range-slider-input);
      // v1 renders it as a passthrough chip and does not proxy the slider.
    } else if (!group.options.length) {
      var empty = document.createElement('div');
      empty.className = 'dw-hfilter-opt-empty';
      empty.textContent = 'No options';
      panel.appendChild(empty);
    } else {
      for (var i = 0; i < group.options.length; i++) {
        panel.appendChild(buildOpt(group, group.options[i]));
      }
    }
    chip.appendChild(panel);

    chip.addEventListener('click', function (e) {
      // Clicks on option rows handle themselves (stopPropagation); this is the chip body.
      if (e.target.closest('.dw-hfilter-panel')) return;
      var isOpen = chip.classList.contains('dw-hfilter-open');
      closePanels();
      if (!isOpen) { chip.classList.add('dw-hfilter-open'); openChip = chip; }
    });

    return chip;
  }

  function renderBar(groups) {
    var existing = document.getElementById(BAR_ID);
    if (existing) existing.remove();

    var bar = document.createElement('div');
    bar.id = BAR_ID;
    bar.className = 'dw-hfilter-bar';

    for (var i = 0; i < groups.length; i++) {
      bar.appendChild(buildChip(groups[i]));
    }

    // Insert as the next sibling after the Boost toolbar container, above the grid.
    var toolbar = document.querySelector(TOOLBAR);
    var grid = document.querySelector(GRID);
    if (toolbar && toolbar.parentElement) {
      toolbar.parentElement.insertBefore(bar, toolbar.nextSibling);
    } else if (grid && grid.parentElement) {
      grid.parentElement.insertBefore(bar, grid);
    } else {
      return false;
    }
    openChip = null;
    return true;
  }

  /* ---- live re-sync of chip active-counts / checkmarks ----------------- */
  function syncBar(groups) {
    var bar = document.getElementById(BAR_ID);
    if (!bar) return;
    for (var i = 0; i < groups.length; i++) {
      var g = groups[i];
      var chip = bar.querySelector('.dw-hfilter-chip[data-dw-group="' + g.index + '"]');
      if (!chip) continue;
      var activeCount = g.options.filter(function (o) { return o.selected; }).length;

      chip.classList.toggle('dw-hfilter-active', !!activeCount);
      var badge = chip.querySelector('.dw-hfilter-badge');
      if (activeCount) {
        if (!badge) {
          badge = document.createElement('span');
          badge.className = 'dw-hfilter-badge';
          chip.insertBefore(badge, chip.querySelector('.dw-hfilter-caret'));
        }
        badge.textContent = activeCount;
      } else if (badge) {
        badge.remove();
      }

      // Update option checkmarks/selected by normalized text (panel order matches model).
      var rows = chip.querySelectorAll('.dw-hfilter-opt');
      for (var r = 0; r < rows.length && r < g.options.length; r++) {
        var sel = g.options[r].selected;
        rows[r].classList.toggle('dw-hfilter-opt-selected', sel);
        var ck = rows[r].querySelector('.dw-hfilter-check');
        if (ck) ck.textContent = sel ? '✓' : '';
      }
    }
  }

  /* ---- outside-click closes panels ------------------------------------- */
  document.addEventListener('click', function (e) {
    if (e.target.closest('#' + BAR_ID)) return;
    closePanels();
  });

  /* ---- fallback toggle (only if the liquid pill is absent) ------------- */
  function ensureFallbackToggle() {
    if (document.getElementById('dw-filter-toggle')) return; // liquid pill owns collapse
    if (document.getElementById('dw-hfilter-toggle')) return;
    var toolbar = document.querySelector(TOOLBAR);
    if (!toolbar || !toolbar.parentElement) return;
    var btn = document.createElement('button');
    btn.id = 'dw-hfilter-toggle';
    btn.className = 'dw-hfilter-toggle';
    btn.type = 'button';
    function sync() {
      btn.textContent = document.body.classList.contains('dw-hbar-collapsed') ? 'Filters' : 'Hide Filters';
    }
    btn.addEventListener('click', function () {
      var collapsed = document.body.classList.toggle('dw-hbar-collapsed');
      try { localStorage.setItem('dw-hfilter-collapsed', collapsed ? '1' : '0'); } catch (e) {}
      sync();
    });
    toolbar.parentElement.insertBefore(btn, toolbar);
    sync();
  }

  /* ---- main wire-up ---------------------------------------------------- */
  var lastSig = '';

  function build() {
    var groups = readGroups();
    if (!groups.length) return false;
    lastSig = groupsSig(groups);
    renderBar(groups);
    ensureFallbackToggle();
    return true;
  }

  function observe() {
    var tree = document.querySelector(TREE);
    var target = (tree && tree.parentElement) || tree;
    if (!target) return;
    var t = null;
    var obs = new MutationObserver(function () {
      clearTimeout(t);
      t = setTimeout(function () {
        var groups = readGroups();
        if (!groups.length) return;
        var sig = groupsSig(groups);
        if (sig !== lastSig || !document.getElementById(BAR_ID)) {
          lastSig = sig;
          renderBar(groups);     // group set changed OR bar was wiped -> re-render wholesale
          ensureFallbackToggle();
        } else {
          syncBar(groups);       // same structure -> just re-sync active state
        }
      }, 150);
    });
    obs.observe(target, { childList: true, subtree: true });
  }

  var tries = 0;
  var poll = setInterval(function () {
    var haveTree = document.querySelector(TREE);
    var haveToolbar = document.querySelector(TOOLBAR);
    if (haveTree && haveToolbar) {
      clearInterval(poll);
      build();        // try now (options may not be rendered yet -> may no-op)
      observe();      // ALWAYS observe, so late-rendered options trigger the build
    } else if (tries++ > 60) {
      clearInterval(poll);
    }
  }, 250);
})();