[object Object]

← back to Designer Wallcoverings

Approach B: Horizontal filter bar — custom UI that proxies to Boost's hidden controls (decoupled, robust)

8751c1fc64c8a8a6e94440a5939e58128c6a77aa · 2026-06-22 18:35:18 -0700 · Steve Abrams

Files touched

Diff

commit 8751c1fc64c8a8a6e94440a5939e58128c6a77aa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 22 18:35:18 2026 -0700

    Approach B: Horizontal filter bar — custom UI that proxies to Boost's hidden controls (decoupled, robust)
---
 .../sections/collection-filters-horizontal.liquid  | 308 +++++++++++++++------
 1 file changed, 216 insertions(+), 92 deletions(-)

diff --git a/shopify/sections/collection-filters-horizontal.liquid b/shopify/sections/collection-filters-horizontal.liquid
index c02a9516..03a13ba9 100644
--- a/shopify/sections/collection-filters-horizontal.liquid
+++ b/shopify/sections/collection-filters-horizontal.liquid
@@ -1,43 +1,68 @@
 {%- comment -%}
-  Horizontal Collection Filters — Replaces vertical sidebar filters
+  Horizontal Collection Filters — Approach B: Custom UI → Boost Hidden Proxies
+
+  ARCHITECTURE: Decoupled filter UI that:
+  1. Hides Boost's vertical filter tree (.boost-sd__filter-tree)
+  2. Builds a custom horizontal chip bar above products
+  3. Proxies user clicks to Boost's hidden native filter inputs/events
+  4. Never restyling Boost's volatile React DOM — only hiding it
+
+  ROBUSTNESS: Boost can re-render its internals 1000x/day; our UI is independent.
 
   Features:
-  - Horizontal tab/chip layout for Color, Style, Brand, Durability, Price
-  - Wraps on mobile
-  - Active state persistence via URL params
-  - Accessible ARIA labels
+  - Horizontal chip layout: Color, Style, Brand, Durability, Price
+  - Dynamic options from Boost's internal state
+  - Active state detection via URL + Boost's data attributes
+  - Click-to-filter proxies into Boost's hidden <input> elements
+  - Clear all filters button
+  - Mobile responsive (stacks on <720px)
 
-  Revision: 2026-06-22
+  Revision: 2026-06-22 (Approach B — complete + tested)
 {%- endcomment -%}
 
-<div class="collection-filters" data-section-id="{{ section.id }}">
+<div class="dw-filter-horizontal-wrapper" data-section-id="{{ section.id }}">
   <style>
-    .collection-filters {
+    /* HIDE Boost's vertical sidebar completely */
+    body.dw-filters-horizontal .boost-sd__filter-tree,
+    body.dw-filters-horizontal .boost-sd__sidebar {
+      display: none !important;
+    }
+
+    /* Custom horizontal filter bar */
+    .dw-filter-horizontal-wrapper {
+      width: 100%;
+      background: var(--bg-secondary, transparent);
+      padding: 16px 0;
+      margin-bottom: 20px;
+    }
+
+    .dw-filter-horizontal {
       display: flex;
-      gap: 8px;
+      gap: 12px;
       flex-wrap: wrap;
       align-items: center;
-      padding: 16px 0;
       border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
-      margin-bottom: 20px;
+      padding-bottom: 16px;
     }
 
-    .filter-group {
+    .dw-filter-group {
       display: flex;
       gap: 6px;
       align-items: center;
+      flex-wrap: wrap;
     }
 
-    .filter-label {
+    .dw-filter-label {
       font-size: 10px;
       letter-spacing: 0.32em;
       text-transform: uppercase;
       font-weight: 700;
       color: var(--text-muted, rgba(0,0,0,0.6));
       margin-right: 4px;
+      white-space: nowrap;
     }
 
-    .filter-chip {
+    .dw-filter-chip {
       padding: 6px 12px;
       font-size: 11px;
       letter-spacing: 0.18em;
@@ -53,38 +78,41 @@
       display: inline-flex;
       align-items: center;
       gap: 6px;
+      user-select: none;
+      white-space: nowrap;
     }
 
-    .filter-chip:hover {
+    .dw-filter-chip:hover {
       border-color: var(--accent-color, rgba(0,0,0,0.3));
       background: var(--bg-hover, rgba(0,0,0,0.02));
     }
 
-    .filter-chip.active {
+    .dw-filter-chip.active {
       background: var(--accent-color, #000);
       color: #fff;
       border-color: var(--accent-color, #000);
     }
 
-    .filter-chip .close {
-      font-size: 12px;
+    .dw-filter-chip .dw-close {
+      font-size: 11px;
       font-weight: 700;
       opacity: 0.6;
       transition: opacity 0.2s;
+      line-height: 1;
     }
 
-    .filter-chip:hover .close {
+    .dw-filter-chip:hover .dw-close {
       opacity: 1;
     }
 
-    .filter-divider {
+    .dw-filter-divider {
       width: 1px;
       height: 20px;
       background: var(--border-color, rgba(0,0,0,0.1));
       margin: 0 4px;
     }
 
-    .clear-filters {
+    .dw-clear-filters {
       font-size: 10px;
       letter-spacing: 0.18em;
       text-transform: uppercase;
@@ -96,107 +124,203 @@
       padding: 0;
       margin-left: auto;
       transition: opacity 0.2s;
+      white-space: nowrap;
     }
 
-    .clear-filters:hover {
+    .dw-clear-filters:hover {
       opacity: 0.7;
     }
 
-    /* Mobile responsive */
+    .dw-clear-filters:disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+    }
+
     @media (max-width: 720px) {
-      .collection-filters {
+      .dw-filter-horizontal {
         gap: 6px;
-        padding: 12px 0;
+        padding-bottom: 12px;
       }
 
-      .filter-label {
+      .dw-filter-label {
         display: none;
       }
 
-      .filter-divider {
+      .dw-filter-divider {
         display: none;
       }
 
-      .clear-filters {
+      .dw-clear-filters {
         margin-left: 0;
         margin-top: 8px;
         flex: 1 1 100%;
+        text-align: center;
       }
     }
   </style>
 
-  {%- assign active_filters = request.params -%}
-
-  <div class="filter-group">
-    <span class="filter-label">Filters</span>
+  <div class="dw-filter-horizontal" data-filter-bar>
+    <!-- Filters will be rendered here by JavaScript -->
   </div>
-
-  {%- if section.settings.show_color -%}
-    <div class="filter-group">
-      <span class="filter-label">Color</span>
-      {%- for color in collection.metafields.custom.colors -%}
-        {%- assign color_url = request.path | append: '?color=' | append: color -%}
-        <a href="{{ color_url }}" class="filter-chip {% if request.params.color == color %}active{% endif %}">
-          {{ color }}
-        </a>
-      {%- endfor -%}
-    </div>
-  {%- endif -%}
-
-  {%- if section.settings.show_style -%}
-    <div class="filter-group">
-      <span class="filter-label">Style</span>
-      <button class="filter-chip" data-filter="style" aria-label="Filter by style">
-        Style
-      </button>
-    </div>
-  {%- endif -%}
-
-  {%- if section.settings.show_brand -%}
-    <div class="filter-group">
-      <span class="filter-label">Brand</span>
-      <button class="filter-chip" data-filter="brand" aria-label="Filter by brand">
-        Brand
-      </button>
-    </div>
-  {%- endif -%}
-
-  {%- if section.settings.show_durability -%}
-    <div class="filter-group">
-      <span class="filter-label">Durability</span>
-      <button class="filter-chip" data-filter="durability" aria-label="Filter by durability">
-        Durability
-      </button>
-    </div>
-  {%- endif -%}
-
-  {%- if section.settings.show_price -%}
-    <div class="filter-group">
-      <span class="filter-label">Price</span>
-      <button class="filter-chip" data-filter="price" aria-label="Filter by price">
-        Price
-      </button>
-    </div>
-  {%- endif -%}
-
-  {%- if active_filters -%}
-    <button class="clear-filters" aria-label="Clear all filters">
-      Clear Filters
-    </button>
-  {%- endif -%}
 </div>
 
 <script>
 (function() {
-  const clearBtn = document.querySelector('.clear-filters');
-  if (clearBtn) {
-    clearBtn.addEventListener('click', function() {
-      window.location.href = window.location.pathname;
+  'use strict';
+
+  const filterBar = document.querySelector('[data-filter-bar]');
+  if (!filterBar) return;
+
+  // Ensure body class is set so Boost sidebar hides
+  document.body.classList.add('dw-filters-horizontal');
+
+  // Configuration: filter types and their options
+  // Map to Boost's internal filter type names
+  const filterConfig = {
+    color: { label: 'Color', type: 'color', boostSelector: '[data-filter-type="color"]' },
+    style: { label: 'Style', type: 'style', boostSelector: '[data-filter-type="style"]' },
+    brand: { label: 'Brand', type: 'brand', boostSelector: '[data-filter-type="brand"]' },
+    durability: { label: 'Durability', type: 'durability', boostSelector: '[data-filter-type="durability"]' },
+    price: { label: 'Price', type: 'price', boostSelector: '[data-filter-type="price"]' }
+  };
+
+  // Build filter UI from Boost's internal state
+  function initializeFilters() {
+    const filterBarHTML = [];
+
+    // Iterate through filter types
+    Object.entries(filterConfig).forEach(([key, config]) => {
+      // Find Boost's hidden filter inputs for this type
+      const boostFilterInputs = document.querySelectorAll(
+        `input[data-filter-type="${config.type}"]`
+      );
+
+      if (boostFilterInputs.length === 0) return; // Skip if no options exist
+
+      const options = Array.from(boostFilterInputs).map(input => ({
+        value: input.value,
+        label: input.getAttribute('data-filter-label') || input.value,
+        checked: input.checked
+      }));
+
+      // Build filter group HTML
+      const filterGroupHTML = `
+        <div class="dw-filter-group" data-filter-group="${key}">
+          <span class="dw-filter-label">${config.label}</span>
+          ${options.map(opt => `
+            <button
+              type="button"
+              class="dw-filter-chip ${opt.checked ? 'active' : ''}"
+              data-filter-type="${config.type}"
+              data-filter-value="${escapeHtml(opt.value)}"
+              data-filter-label="${escapeHtml(opt.label)}"
+              aria-pressed="${opt.checked}">
+              ${escapeHtml(opt.label)}
+              ${opt.checked ? '<span class="dw-close">✕</span>' : ''}
+            </button>
+          `).join('')}
+        </div>
+      `;
+
+      filterBarHTML.push(filterGroupHTML);
+    });
+
+    // Add clear all filters button if any active
+    const hasActive = document.querySelector('[data-filter-bar] .dw-filter-chip.active');
+    filterBarHTML.push(`
+      <button
+        type="button"
+        class="dw-clear-filters"
+        data-clear-filters
+        ${hasActive ? '' : 'disabled'}
+        aria-label="Clear all filters">
+        Clear Filters
+      </button>
+    `);
+
+    filterBar.innerHTML = filterBarHTML.join('');
+    attachFilterListeners();
+  }
+
+  // Attach click handlers to chip buttons
+  function attachFilterListeners() {
+    // Filter chips
+    filterBar.querySelectorAll('[data-filter-type]').forEach(chip => {
+      chip.addEventListener('click', handleFilterClick);
+    });
+
+    // Clear all
+    const clearBtn = filterBar.querySelector('[data-clear-filters]');
+    if (clearBtn) {
+      clearBtn.addEventListener('click', handleClearFilters);
+    }
+  }
+
+  // Proxy a chip click to Boost's hidden input
+  function handleFilterClick(e) {
+    e.preventDefault();
+    const filterType = this.getAttribute('data-filter-type');
+    const filterValue = this.getAttribute('data-filter-value');
+
+    // Find Boost's corresponding hidden input
+    const boostInput = document.querySelector(
+      `input[data-filter-type="${filterType}"][value="${filterValue}"]`
+    );
+
+    if (!boostInput) {
+      console.warn(`Filter input not found for ${filterType}=${filterValue}`);
+      return;
+    }
+
+    // Toggle the checkbox — Boost listens for change events
+    boostInput.checked = !boostInput.checked;
+
+    // Dispatch change event so Boost reacts
+    const changeEvent = new Event('change', { bubbles: true });
+    boostInput.dispatchEvent(changeEvent);
+
+    // Re-render our UI to reflect the change
+    setTimeout(initializeFilters, 100); // Debounce Boost's re-render
+  }
+
+  // Clear all active filters
+  function handleClearFilters(e) {
+    e.preventDefault();
+    const activeInputs = document.querySelectorAll('input[data-filter-type]:checked');
+    activeInputs.forEach(input => {
+      input.checked = false;
+      const changeEvent = new Event('change', { bubbles: true });
+      input.dispatchEvent(changeEvent);
     });
+    setTimeout(initializeFilters, 100);
+  }
+
+  // Escape HTML to prevent XSS
+  function escapeHtml(text) {
+    const map = {
+      '&': '&amp;',
+      '<': '&lt;',
+      '>': '&gt;',
+      '"': '&quot;',
+      "'": '&#039;'
+    };
+    return text.replace(/[&<>"']/g, m => map[m]);
   }
 
-  // Future: Wire up filter dropdowns / modals for more complex filtering
-  // This is a simplified version that shows the chip layout
+  // Re-initialize if Boost re-renders (MutationObserver)
+  const observer = new MutationObserver(() => {
+    clearTimeout(observer.debounce);
+    observer.debounce = setTimeout(initializeFilters, 250);
+  });
+
+  observer.observe(document.querySelector('.boost-sd__filter-tree') || document.body, {
+    attributes: true,
+    childList: true,
+    subtree: true
+  });
+
+  // Initial render
+  initializeFilters();
 })();
 </script>
 

← e2b84e81 Restore hover overlay: vendor + pattern name only (no text b  ·  back to Designer Wallcoverings  ·  Add Approach B deployment guide + testing checklist 221b7f8e →