← 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
M shopify/sections/collection-filters-horizontal.liquid
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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ 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 →