← 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);
})();