[object Object]

← back to Dw Validator Debug TK11314

improve Shopify purchase and collection UX

a6461a60846ee1689e81b43754feaf9e57061642 · 2026-08-27 19:49:24 -0700 · Steve

Files touched

Diff

commit a6461a60846ee1689e81b43754feaf9e57061642
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 19:49:24 2026 -0700

    improve Shopify purchase and collection UX
---
 shopify/sections/collection-toolbar.liquid         |  19 ++--
 shopify/tests/theme-ux.test.js                     |  75 +++++++++++++++
 .../assets/dw-pdp-restyle.css                      |  70 +++++++++++++-
 .../assets/dw-pdp-size-pills.js                    | 103 ++++++++++++++++++++-
 .../layout/theme.liquid                            |   6 --
 5 files changed, 248 insertions(+), 25 deletions(-)

diff --git a/shopify/sections/collection-toolbar.liquid b/shopify/sections/collection-toolbar.liquid
index 7cb2e83e..329d043d 100644
--- a/shopify/sections/collection-toolbar.liquid
+++ b/shopify/sections/collection-toolbar.liquid
@@ -169,9 +169,8 @@
         margin-top: 8px;
       }
 
-      .density-control {
-        display: none;
-      }
+      .density-control { flex: 1 1 100%; }
+      .density-control input[type="range"] { flex: 1 1 auto; }
     }
   </style>
 
@@ -218,27 +217,27 @@
   // Load saved state
   try {
     const savedSort = localStorage.getItem(STORAGE_KEY_SORT);
-    if (savedSort) sortSelect.value = savedSort;
+    if (savedSort && sortSelect) sortSelect.value = savedSort;
 
     const savedDensity = localStorage.getItem(STORAGE_KEY_DENSITY);
-    if (savedDensity) {
+    if (savedDensity && densitySlider && densityLabel) {
       densitySlider.value = savedDensity;
       densityLabel.textContent = savedDensity;
       updateGridDensity(parseInt(savedDensity));
     }
 
     const savedSearch = localStorage.getItem(STORAGE_KEY_SEARCH);
-    if (savedSearch) searchInput.value = savedSearch;
+    if (savedSearch && searchInput) searchInput.value = savedSearch;
   } catch (e) {}
 
   // Sort change
-  sortSelect.addEventListener('change', function() {
+  if (sortSelect) sortSelect.addEventListener('change', function() {
     try { localStorage.setItem(STORAGE_KEY_SORT, this.value); } catch (e) {}
     applySort(this.value);
   });
 
   // Density slider
-  densitySlider.addEventListener('input', function() {
+  if (densitySlider) densitySlider.addEventListener('input', function() {
     const cols = parseInt(this.value);
     densityLabel.textContent = cols;
     try { localStorage.setItem(STORAGE_KEY_DENSITY, cols); } catch (e) {}
@@ -246,7 +245,7 @@
   });
 
   // Search
-  searchInput.addEventListener('input', debounce(function() {
+  if (searchInput) searchInput.addEventListener('input', debounce(function() {
     try { localStorage.setItem(STORAGE_KEY_SEARCH, this.value); } catch (e) {}
     applySearch(this.value);
   }, 300));
@@ -295,7 +294,7 @@
 
   // Initialize grid on page load
   window.addEventListener('load', function() {
-    updateGridDensity(parseInt(densitySlider.value));
+    if (densitySlider) updateGridDensity(parseInt(densitySlider.value));
   });
 })();
 </script>
diff --git a/shopify/tests/theme-ux.test.js b/shopify/tests/theme-ux.test.js
new file mode 100644
index 00000000..b7b0977b
--- /dev/null
+++ b/shopify/tests/theme-ux.test.js
@@ -0,0 +1,75 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const vm = require('node:vm');
+
+const theme = path.resolve(__dirname, '../theme-LIVE-pull-20260728-colorbar');
+
+function read(relativePath) {
+  return fs.readFileSync(path.join(theme, relativePath), 'utf8');
+}
+
+function loadPurchaseOptions() {
+  const source = read('assets/dw-pdp-size-pills.js');
+  const sandbox = {
+    window: { addEventListener() {} },
+    document: { readyState: 'loading', addEventListener() {} },
+    Event: function Event() {}
+  };
+  vm.runInNewContext(source, sandbox);
+  return sandbox.window.DWPdpPurchaseOptions;
+}
+
+test('theme loads the grid controller only once', () => {
+  const layout = read('layout/theme.liquid');
+  assert.equal((layout.match(/dw-grid-control\.js/g) || []).length, 1);
+});
+
+test('purchase choices classify samples and full-size formats', () => {
+  const options = loadPurchaseOptions();
+  assert.equal(options.optionKind('Sample - $4.25', 'ABC-Sample'), 'sample');
+  assert.equal(options.optionKind('Sold Per Roll - $205.00', 'ABC'), 'roll');
+  assert.equal(options.optionKind('Bolt - $450.00', 'ABC'), 'bolt');
+  assert.equal(options.optionKind('Sold by the Yard - $89.00', 'ABC'), 'yardage');
+});
+
+test('purchase choice copy separates title, detail, and price', () => {
+  const options = loadPurchaseOptions();
+  const copy = options.optionCopy({
+    textContent: 'Sold Per Roll - 20.5 in wide x 11 yds - $205.00',
+    value: 'roll',
+    getAttribute() { return 'ABC'; }
+  });
+  assert.equal(copy.title, 'Buy a roll');
+  assert.match(copy.detail, /Sold Per Roll/);
+  assert.equal(copy.price, '$205.00');
+});
+
+test('mobile purchase bar delegates to the native cart control', () => {
+  const source = read('assets/dw-pdp-size-pills.js');
+  const css = read('assets/dw-pdp-restyle.css');
+  assert.match(source, /currentAction\.click\(\)/);
+  assert.match(source, /isSample && sampleButton \? sampleButton : nativeButton/);
+  assert.match(source, /isSample \? 'Order sample'/);
+  assert.match(css, /\.dw-mobile-purchase\s*\{/);
+  assert.match(css, /position:\s*fixed/);
+  assert.match(css, /env\(safe-area-inset-bottom\)/);
+});
+
+test('search accessibility repair targets missing translation labels', () => {
+  const source = read('assets/dw-pdp-size-pills.js');
+  assert.match(source, /Translation missing/);
+  assert.match(source, /Search products/);
+});
+
+test('collection toolbar retains complete sort and persistent density controls', () => {
+  const toolbar = fs.readFileSync(path.resolve(__dirname, '../sections/collection-toolbar.liquid'), 'utf8');
+  for (const value of ['newest', 'color', 'style', 'sku', 'title', 'price-asc', 'price-desc']) {
+    assert.match(toolbar, new RegExp(`value="${value}"`));
+  }
+  assert.match(toolbar, /type="range"[^>]+id="densitySlider"/);
+  assert.match(toolbar, /dw_collection_sort/);
+  assert.match(toolbar, /dw_collection_density/);
+  assert.match(toolbar, /if \(searchInput\) searchInput\.addEventListener/);
+});
diff --git a/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-restyle.css b/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-restyle.css
index 3ca14bec..8fe79478 100644
--- a/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-restyle.css
+++ b/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-restyle.css
@@ -42,6 +42,14 @@
   margin: 6px 0 18px;
 }
 
+.template-product .dw-purchase-options__heading {
+  flex: 1 0 100%;
+  margin: 0 0 2px;
+  font: 600 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  letter-spacing: .01em;
+  text-transform: none;
+}
+
 .template-product .dw-size-pill {
   font-family: var(--dw-pill-font);
   font-weight: 700;
@@ -50,15 +58,41 @@
   text-transform: uppercase;
   line-height: 1.1;
   cursor: pointer;
-  padding: 14px 22px;
-  min-height: 46px;
-  border-radius: var(--dw-pill-radius);
+  padding: 14px 16px;
+  min-height: 92px;
+  border-radius: 12px;
   border: 1.5px solid #111;
   background: #fff;
   color: #111;
   transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
   -webkit-appearance: none;
   appearance: none;
+  flex: 1 1 calc(50% - 5px);
+  text-align: left;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  align-content: center;
+  gap: 4px 12px;
+}
+
+.template-product .dw-size-pill__title {
+  grid-column: 1;
+  font-size: 13px;
+}
+
+.template-product .dw-size-pill__detail {
+  grid-column: 1 / -1;
+  font: 400 12px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  letter-spacing: 0;
+  text-transform: none;
+  opacity: .72;
+}
+
+.template-product .dw-size-pill__price {
+  grid-column: 2;
+  grid-row: 1;
+  font-size: 14px;
+  white-space: nowrap;
 }
 
 .template-product .dw-size-pill:hover {
@@ -159,6 +193,34 @@
   }
 }
 
+.dw-mobile-purchase { display: none; }
+
+@media screen and (max-width: 720px) {
+  body.template-product { padding-bottom: 84px; }
+  .template-product .dw-size-pill { flex-basis: 100%; }
+  .dw-mobile-purchase {
+    position: fixed;
+    z-index: 2147483000;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 14px;
+    padding: 10px max(14px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
+    border-top: 1px solid rgba(0,0,0,.14);
+    background: rgba(255,255,255,.97);
+    box-shadow: 0 -8px 24px rgba(0,0,0,.08);
+    backdrop-filter: blur(12px);
+  }
+  .dw-mobile-purchase__summary { min-width: 0; display: grid; gap: 2px; }
+  .dw-mobile-purchase__summary span { font: 500 10px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-transform: uppercase; letter-spacing: .08em; opacity: .62; }
+  .dw-mobile-purchase__summary strong { overflow: hidden; font: 700 13px/1.25 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-overflow: ellipsis; white-space: nowrap; }
+  .dw-mobile-purchase__button { flex: 0 0 auto; min-height: 48px; padding: 0 20px; border: 1px solid #111; border-radius: 99px; background: #111; color: #fff; font: 700 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-transform: uppercase; letter-spacing: .04em; }
+  .dw-mobile-purchase__button:disabled { opacity: .45; }
+}
+
 /* ── DW 2026-06-25: keep the printable Tear Sheet (.dw-spec-page) OUT of the
    on-screen PDP. It was rendering inline — a duplicate <h1> product title +
    the Purchasing-Agency address block. Off-screen (NOT display:none) so the
@@ -185,6 +247,6 @@
 
 /* DW 2026-06-25: Download-PDF pill joins the size-pill row next to Sample; min L/R padding, aligned. */
 .template-product .dw-size-pills{align-items:center;}
-.template-product .dw-size-pill{padding-left:12px !important;padding-right:12px !important;}
+.template-product .dw-size-pill{padding-left:16px !important;padding-right:16px !important;}
 .template-product .dw-size-pills .spec-sheet-controls{margin:0 !important;transform:none !important;}
 .template-product .dw-size-pills .spec-sheet-btn.pdf-download-btn{width:auto !important;max-width:none !important;min-height:46px;padding:12px !important;border-radius:var(--dw-pill-radius) !important;background:#fff;color:#111;border:1.5px solid #111;font:700 12px/1.1 var(--dw-pill-font);letter-spacing:.02em;text-transform:uppercase;}
diff --git a/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-size-pills.js b/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-size-pills.js
index c85488dd..5cc2c6e6 100644
--- a/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-size-pills.js
+++ b/shopify/theme-LIVE-pull-20260728-colorbar/assets/dw-pdp-size-pills.js
@@ -5,7 +5,7 @@
  * <select class="single-option-selector"> that the theme's variant /
  * cart / cowlendar / MOQ JS reads. We:
  *   1. keep the <select> in the DOM (visually hidden via dw-pdp-restyle.css),
- *   2. render one clickable pill <button> per <option>,
+ *   2. render one explicit purchase card <button> per <option>,
  *   3. on click -> set select.value + dispatch a native 'change' event so
  *      the existing jQuery `.on('change', '.single-option-selector', ...)'
  *      handler in custom.js.liquid (and everything downstream) fires exactly
@@ -20,6 +20,36 @@
 
   var SELECTOR = 'select.single-option-selector';
 
+  function optionKind(text, sku) {
+    var value = ((text || '') + ' ' + (sku || '')).toLowerCase();
+    if (/sample/.test(value)) return 'sample';
+    if (/roll/.test(value)) return 'roll';
+    if (/bolt/.test(value)) return 'bolt';
+    if (/yard|metre|meter/.test(value)) return 'yardage';
+    return 'product';
+  }
+
+  function optionCopy(option) {
+    var raw = (option.textContent || option.value || '').trim();
+    var parts = raw.split(/\s+-\s+(?=\$)/);
+    var kind = optionKind(raw, option.getAttribute('data-sku'));
+    var title = kind === 'sample' ? 'Order a sample' :
+      kind === 'roll' ? 'Buy a roll' :
+      kind === 'bolt' ? 'Buy a bolt' :
+      kind === 'yardage' ? 'Buy by the yard' : 'Select this option';
+    return {
+      kind: kind,
+      title: title,
+      detail: parts[0] || raw,
+      price: parts.length > 1 ? parts.slice(1).join(' - ') : ''
+    };
+  }
+
+  window.DWPdpPurchaseOptions = {
+    optionKind: optionKind,
+    optionCopy: optionCopy
+  };
+
   function buildPills(select) {
     if (!select || select.dataset.dwPilled === '1') return;
     // Only enhance real size/option selectors with 2+ choices.
@@ -34,18 +64,31 @@
     if (styledShell) { styledShell.style.display = 'none'; }
 
     var wrap = document.createElement('div');
-    wrap.className = 'dw-size-pills';
+    wrap.className = 'dw-size-pills dw-purchase-options';
     wrap.setAttribute('role', 'radiogroup');
     var lbl = select.getAttribute('data-option-name') ||
               (select.options[0] && select.options[0].closest ? '' : '');
-    wrap.setAttribute('aria-label', lbl || 'Size');
+    wrap.setAttribute('aria-label', lbl && lbl.toLowerCase() !== 'title' ? lbl : 'Choose sample or full-size product');
+
+    var heading = document.createElement('p');
+    heading.className = 'dw-purchase-options__heading';
+    heading.textContent = 'Choose how you would like to order';
+    wrap.appendChild(heading);
 
     opts.forEach(function (opt) {
       var btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'dw-size-pill';
       btn.setAttribute('role', 'radio');
-      btn.textContent = (opt.textContent || opt.value || '').trim();
+      var copy = optionCopy(opt);
+      btn.classList.add('dw-size-pill--' + copy.kind);
+      btn.setAttribute('aria-label', [copy.title, copy.detail, copy.price].filter(Boolean).join(', '));
+      btn.innerHTML = '<span class="dw-size-pill__title"></span>' +
+        '<span class="dw-size-pill__detail"></span>' +
+        '<strong class="dw-size-pill__price"></strong>';
+      btn.querySelector('.dw-size-pill__title').textContent = copy.title;
+      btn.querySelector('.dw-size-pill__detail').textContent = copy.detail;
+      btn.querySelector('.dw-size-pill__price').textContent = copy.price;
       btn.dataset.value = opt.value;
       if (opt.disabled) {
         btn.disabled = true;
@@ -108,10 +151,60 @@
     Array.prototype.forEach.call(selects, buildPills);
   }
 
+  function repairSearchLabels() {
+    var fields = document.querySelectorAll('input[aria-label*="Translation missing"], input[aria-label=""]');
+    Array.prototype.forEach.call(fields, function (field) {
+      if (field.type === 'search' || /search/i.test(field.name || field.placeholder || '')) {
+        field.setAttribute('aria-label', 'Search products');
+      }
+    });
+  }
+
+  function buildMobilePurchaseBar() {
+    if (!document.body.classList.contains('template-product') || document.querySelector('.dw-mobile-purchase')) return;
+    var nativeButton = document.querySelector('.product-add-to-cart .add-to-cart');
+    if (!nativeButton) return;
+    var bar = document.createElement('div');
+    bar.className = 'dw-mobile-purchase';
+    bar.setAttribute('aria-label', 'Product purchase actions');
+    bar.innerHTML = '<div class="dw-mobile-purchase__summary"><span>Selected option</span><strong></strong></div>' +
+      '<button type="button" class="dw-mobile-purchase__button">Add to cart</button>';
+    document.body.appendChild(bar);
+    var summary = bar.querySelector('strong');
+    var button = bar.querySelector('button');
+    var currentAction = nativeButton;
+
+    function sync() {
+      var active = document.querySelector('.dw-size-pill.is-active');
+      var title = active && active.querySelector('.dw-size-pill__title');
+      var price = active && active.querySelector('.dw-size-pill__price');
+      var isSample = active && active.classList.contains('dw-size-pill--sample');
+      var sampleButton = document.querySelector('.product-add-to-cart .dl-sample-btn:not(.unavailable), .product-add-to-cart .dl-second-sample-btn:not(.unavailable)');
+      currentAction = isSample && sampleButton ? sampleButton : nativeButton;
+      summary.textContent = [title && title.textContent, price && price.textContent].filter(Boolean).join(' · ') || 'Ready to order';
+      button.disabled = Boolean(currentAction.disabled || currentAction.classList.contains('disabled') || currentAction.classList.contains('unavailable'));
+      button.textContent = isSample ? 'Order sample' : (nativeButton.value || nativeButton.textContent || 'Add to cart');
+    }
+
+    button.addEventListener('click', function () {
+      currentAction.click();
+    });
+    document.addEventListener('change', function (event) {
+      if (event.target.matches && event.target.matches('.single-option-selector, .product-select')) setTimeout(sync, 0);
+    });
+    sync();
+  }
+
   if (document.readyState === 'loading') {
-    document.addEventListener('DOMContentLoaded', init);
+    document.addEventListener('DOMContentLoaded', function () {
+      init();
+      repairSearchLabels();
+      buildMobilePurchaseBar();
+    });
   } else {
     init();
+    repairSearchLabels();
+    buildMobilePurchaseBar();
   }
   // Re-run shortly after load in case theme JS injects/reorders selectors.
   window.addEventListener('load', function () { setTimeout(init, 600); });
diff --git a/shopify/theme-LIVE-pull-20260728-colorbar/layout/theme.liquid b/shopify/theme-LIVE-pull-20260728-colorbar/layout/theme.liquid
index d9a024be..b7de4f9b 100644
--- a/shopify/theme-LIVE-pull-20260728-colorbar/layout/theme.liquid
+++ b/shopify/theme-LIVE-pull-20260728-colorbar/layout/theme.liquid
@@ -64,12 +64,6 @@
     .contact { margin-top: 24px; font-size: 14px; color: #999; }
     .contact a { color: #2563eb; text-decoration: none; }
   </style>
-<script src="{{ 'dw-grid-control.js' | asset_url }}" defer></script>
-
-
-
-
-
 <!-- cache-bust: 2026-03-25T00:54:55.724677 -->
 </head>
 <body>

← 3c6d03f6 block sample prices from roll imports  ·  back to Dw Validator Debug TK11314  ·  make free sample entitlement reliable 2ecca051 →