1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 VARIATION SWATCHES (W-131) â widget behavior (Phase-2 widget system) 3 4 PROGRESSIVE ENHANCEMENT over WooCommerce's native variable-product 5 `.variations_form`. The native <select> is BOTH the JS-off fallback AND the 6 source of truth; this file paints tappable swatches (color chips / image 7 thumbs / size pills) as a radiogroup over each attribute row and, on 8 activation, sets the select's value and dispatches a native `change` â so 9 WooCommerce's own variations_form.js does ALL the real work (price, 10 availability, gallery image, the hidden variation_id, add-to-cart enable). 11 12 We never fork add-to-cart, register no endpoint, and emit no new hzo:* event 13 (native `change` is WC's contract; hzo.announce is an API, not a DOM event). 14 Out-of-stock / incompatible combinations are struck by MIRRORING WC's own 15 per-option `disabled` state â caught with a MutationObserver so it stays 16 accurate on every recompute, with or without jQuery. 17 18 Self-contained, no hard dependencies: 19 · Bails at load unless a `.variations_form` exists (near-zero off-product). 20 · hzo.announce (W-021) is guarded at event time â never a load-order dep. 21 · jQuery is used ONLY when present (WC's own bus) to commit the value the way 22 WC binds; a native fallback keeps the select correct without it. 23 24 A11y (WAI-ARIA APG Radio Group, explicit-activation variant): each attribute 25 group is role=radiogroup; each swatch role=radio + aria-checked, roving 26 tabindex, Arrow/Home/End move focus, Space/Enter activate. Selection does NOT 27 follow focus (deliberate â arrowing across an out-of-stock swatch must not 28 silently change the cart-bound selection; OOS swatches stay focusable + 29 discoverable, aria-disabled, and announce their unavailability on activation 30 so focus is never stranded â audit G22). Every swatch carries a real 31 aria-label (the term name); chips/thumbs are aria-hidden; the hover/focus tip 32 is a supplement, never the accessible name (§11.3). 33 34 Reduced motion / forced-colors / print handled in swatches.css. 35 36 Public API: window.hzo.swatches = { init, enhance } (init(scope) for 37 AJAX-injected content â e.g. a Quick-View modal, W-132). 38 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 39(function () { 40 'use strict'; 41 42 /* ââ i18n bridge (guarded â JS strings translate on WPML/Polylang) âââââââ */ 43 var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null; 44 function __(s) { return (i18n && i18n.__) ? i18n.__(s, 'hozio-studio') : s; } 45 function fmt(str) { 46 var args = arguments, i = 1; 47 if (i18n && i18n.sprintf) { return i18n.sprintf.apply(i18n, args); } 48 return String(str).replace(/%s/g, function () { return args[i++]; }); 49 } 50 51 /* ââ Shared-primitive guards (event-time, never parse-time) ââââââââââââââ */ 52 function announce(msg) { 53 if (msg && window.hzo && typeof window.hzo.announce === 'function') { 54 window.hzo.announce(msg, 'polite'); 55 } 56 } 57 function warn(msg) { if (window.console && console.warn) { console.warn('[hzo_swatches] ' + msg); } } 58 59 var FORM_SELECTOR = 'form.variations_form, .variations_form'; 60 61 /* ââ Config marker (optional) â per-page overrides âââââââââââââââââââââââ */ 62 function parseMap(str) { 63 var out = {}; 64 if (!str) { return out; } 65 var pairs = String(str).split('|'); 66 for (var i = 0; i < pairs.length; i++) { 67 var idx = pairs[i].indexOf(':'); // first colon only (URLs keep theirs) 68 if (idx === -1) { continue; } 69 var k = pairs[i].slice(0, idx).trim(); 70 var v = pairs[i].slice(idx + 1).trim(); 71 if (k) { out[k] = v; } 72 } 73 return out; 74 } 75 76 function readConfig() { 77 var cfg = { form: '', type: 'auto', types: {}, colors: {}, images: {}, tooltip: true, label: true }; 78 var node = document.querySelector('.hzo-swatches-config[data-hzo-swatches]'); 79 if (!node) { return cfg; } 80 cfg.form = node.getAttribute('data-hzo-swatches-form') || ''; 81 var t = node.getAttribute('data-hzo-swatches-type'); 82 if (t) { cfg.type = t; } 83 cfg.types = parseMap(node.getAttribute('data-hzo-swatches-types')); 84 cfg.colors = parseMap(node.getAttribute('data-hzo-swatches-colors')); 85 cfg.images = parseMap(node.getAttribute('data-hzo-swatches-images')); 86 if (node.getAttribute('data-hzo-swatches-tooltip') === 'false') { cfg.tooltip = false; } 87 if (node.getAttribute('data-hzo-swatches-label') === 'false') { cfg.label = false; } 88 return cfg; 89 } 90 91 /* ââ Small DOM/value helpers âââââââââââââââââââââââââââââââââââââââââââââ */ 92 function attrName(select) { 93 return select.getAttribute('name') || select.getAttribute('data-attribute_name') || ''; 94 }
95 function attrSlug(select) { return attrName(select).replace(/^attribute_/, ''); } 96 97 function isColor(v) { 98 if (!v) { return false; } 99 if (v.charAt(0) === '#') { return true; } 100 if (window.CSS && window.CSS.supports) { return window.CSS.supports('color', v); } 101 return false; 102 } 103 104 function groupLabel(select) { 105 if (select.id) { 106 var l = document.querySelector('label[for="' + select.id + '"]'); 107 if (l) { return (l.textContent || '').trim(); } 108 } 109 var td = select.closest ? select.closest('td') : null; 110 var tr = td ? td.parentNode : null; 111 var th = tr ? tr.querySelector('.label, th') : null; 112 if (th) { return (th.textContent || '').trim(); } 113 return __('Options'); 114 } 115 116 function parseVariations(form) { 117 var raw = form.getAttribute('data-product_variations'); 118 if (!raw || raw === 'false') { return null; } // WC AJAX-threshold mode 119 try { return JSON.parse(raw); } catch (e) { return null; } 120 } 121 122 function variationImage(variations, slug, value) { 123 if (!variations) { return ''; } 124 var key = 'attribute_' + slug; 125 for (var i = 0; i < variations.length; i++) { 126 var a = variations[i].attributes || {}; 127 if (a[key] === value && variations[i].image && variations[i].image.src) { 128 return variations[i].image.src; 129 } 130 } 131 return ''; 132 } 133 134 function stripHtml(html) { 135 var d = document.createElement('div'); 136 d.innerHTML = html; // read-only: only textContent is used 137 return (d.textContent || '').replace(/\s+/g, ' ').trim(); 138 } 139 140 function resolveType(slug, cfg) { 141 var bare = slug.replace(/^pa_/, ''); 142 if (cfg.types[slug]) { return cfg.types[slug]; } 143 if (cfg.types[bare]) { return cfg.types[bare]; } 144 if (cfg.type && cfg.type !== 'auto') { return cfg.type; } 145 if (/colou?r/.test(slug)) { return 'color'; } // image is opt-in only 146 return 'pill'; 147 } 148 149 /* ââ Swatch construction âââââââââââââââââââââââââââââââââââââââââââââââââ */ 150 function makeSwatch(opt, type, slug, cfg, variations) { 151 var value = opt.value; 152 var name = (opt.textContent || value).trim(); 153 var btn = document.createElement('button'); 154 btn.type = 'button'; 155 btn.setAttribute('role', 'radio'); 156 btn.setAttribute('aria-checked', 'false'); 157 btn.setAttribute('tabindex', '-1'); 158 btn.setAttribute('data-value', value); 159 btn.setAttribute('aria-label', name); 160 161 var resolved = type; 162 163 if (type === 'color') { 164 var color = cfg.colors[value] || opt.getAttribute('data-hzo-swatch-color') || (isColor(value) ? value : ''); 165 if (color) { 166 btn.style.setProperty('--hzo-swatch-color', color); 167 var chip = document.createElement('span'); 168 chip.className = 'hzo-swatches__chip'; 169 chip.setAttribute('aria-hidden', 'true'); 170 btn.appendChild(chip); 171 } else { 172 resolved = 'pill'; // unresolved color â per-swatch pill fallback 173 } 174 } else if (type === 'image') { 175 var img = cfg.images[value] || variationImage(variations, slug, value); 176 if (img) { 177 var thumb = document.createElement('span'); 178 thumb.className = 'hzo-swatches__chip hzo-swatches__chip--image'; 179 thumb.setAttribute('aria-hidden', 'true'); 180 thumb.style.backgroundImage = 'url("' + img.replace(/["\\]/g, '') + '")'; 181 btn.appendChild(thumb); 182 } else { 183 resolved = 'pill'; 184 } 185 } 186 187 btn.className = 'hzo-swatches__swatch hzo-swatches__swatch--' + resolved; 188 189 if (resolved === 'pill') { 190 var text = document.createElement('span'); 191 text.className = 'hzo-swatches__text'; 192 text.textContent = name; 193 btn.appendChild(text); 194 } else if (cfg.tooltip) { 195 // Supplemental tip for chip swatches (no visible text). The real 196 // accessible name is aria-label above; this tip is aria-hidden. 197 var tip = document.createElement('span'); 198 tip.className = 'hzo-swatches__tip'; 199 tip.setAttribute('aria-hidden', 'true'); 200 tip.textContent = name; 201 btn.appendChild(tip); 202 } 203 204 if (opt.disabled) { markUnavailable(btn, true); } 205 return btn; 206 } 207 208 function markUnavailable(btn, on) { 209 if (on) { 210 btn.setAttribute('aria-disabled', 'true'); 211 btn.classList.add('is-unavailable'); 212 } else { 213 btn.removeAttribute('aria-disabled'); 214 btn.classList.remove('is-unavailable'); 215 } 216 } 217 218 function buildGroup(select, cfg, variations) { 219 var slug = attrSlug(select); 220 var type = resolveType(slug, cfg); 221 var group = document.createElement('div'); 222 group.className = 'hzo-swatches'; 223 group.setAttribute('role', 'radiogroup'); 224 group.setAttribute('aria-label', groupLabel(select)); 225 group.setAttribute('data-hzo-swatches-for', select.id || slug); 226 group.setAttribute('data-hzo-swatches-type', type); 227 228 var made = 0; 229 var options = select.options; 230 for (var i = 0; i < options.length; i++) { 231 var opt = options[i]; 232 if (!opt.value) { continue; } // skip "Choose an option" 233 group.appendChild(makeSwatch(opt, type, slug, cfg, variations)); 234 made++; 235 } 236 if (!made) { return null; } 237 238 if (cfg.label) { 239 var sel = document.createElement('span'); 240 sel.className = 'hzo-swatches__selected'; 241 sel.setAttribute('aria-hidden', 'true'); 242 group.appendChild(sel); 243 } 244 return group; 245 } 246 247 /* ââ Group state helpers âââââââââââââââââââââââââââââââââââââââââââââââââ */ 248 function swatches(group) { return group.querySelectorAll('.hzo-swatches__swatch'); } 249 function swatchName(sw) { return sw.getAttribute('aria-label') || sw.getAttribute('data-value') || ''; } 250 251 function optionFor(select, value) { 252 var opts = select.options; 253 for (var i = 0; i < opts.length; i++) { if (opts[i].value === value) { return opts[i]; } } 254 return null; 255 } 256 257 function setRoving(list, target) { 258 for (var i = 0; i < list.length; i++) { 259 list[i].setAttribute('tabindex', list[i] === target ? '0' : '-1'); 260 } 261 } 262 function firstEnabled(list) { 263 for (var i = 0; i < list.length; i++) { 264 if (list[i].getAttribute('aria-disabled') !== 'true') { return list[i]; } 265 } 266 return null; 267 } 268 269 // Reflect the select's current value + WC's per-option disabled state onto 270 // the swatches. Idempotent â safe to run from click, change, or observer. 271 function syncGroup(group, select) { 272 var val = select.value; 273 var list = swatches(group); 274 var checked = null; 275 for (var i = 0; i < list.length; i++) { 276 var sw = list[i]; 277 var v = sw.getAttribute('data-value'); 278 var opt = optionFor(select, v); 279 markUnavailable(sw, !!(opt && opt.disabled)); 280 var on = (val !== '' && v === val); 281 sw.setAttribute('aria-checked', on ? 'true' : 'false'); 282 sw.classList.toggle('is-selected', on); 283 if (on) { checked = sw; } 284 } 285 var rove = checked || firstEnabled(list) || list[0]; 286 if (rove) { setRoving(list, rove); } 287 var label = group.querySelector('.hzo-swatches__selected'); 288 if (label) { label.textContent = checked ? swatchName(checked) : ''; } 289 } 290 291 /* ââ Commit a value the way WooCommerce is listening âââââââââââââââââââââ */ 292 function commitValue(select, value) { 293 if (window.jQuery) { 294 // WC binds change through jQuery; set + trigger once via the same bus. 295 try { window.jQuery(select).val(value).trigger('change'); return; } catch (e) { /* fall through */ } 296 } 297 select.value = value; 298 var ev; 299 try { ev = new Event('change', { bubbles: true }); } 300 catch (e2) { ev = document.createEvent('Event'); ev.initEvent('change', true, false); } 301 select.dispatchEvent(ev); 302 } 303 304 /* ââ Selection announcement (SR) â LIVE-REGION-OUTSIDE-FRAGMENT via W-021 ââ */ 305 function selectsIn(form) { return form.querySelectorAll('.variations select'); } 306 function selectedName(select) { 307 var o = select.options[select.selectedIndex]; 308 return o ? (o.textContent || o.value).trim() : select.value; 309 } 310 311 function matchVariation(variations, list) { 312 if (!variations) { return null; } 313 for (var i = 0; i < variations.length; i++) { 314 var attrs = variations[i].attributes || {}; 315 var ok = true; 316 for (var j = 0; j < list.length; j++) { 317 var name = attrName(list[j]); 318 var want = list[j].value; 319 var has = attrs[name]; 320 // An empty variation attribute value matches ANY chosen value. 321 if (has !== undefined && has !== '' && has !== want) { ok = false; break; } 322 } 323 if (ok) { return variations[i]; } 324 } 325 return null; 326 } 327 328 function announceSelection(form, variations) { 329 var list = selectsIn(form); 330 var chosen = []; 331 var allChosen = true; 332 for (var i = 0; i < list.length; i++) { 333 if (!list[i].value) { allChosen = false; } 334 else { chosen.push(selectedName(list[i])); } 335 } 336 if (!chosen.length) { announce(__('Selection cleared.')); return; } 337 if (!allChosen || !variations) { announce(chosen.join(', ')); return; } 338 339 var match = matchVariation(variations, list); 340 if (!match) { announce(fmt(__('%s is unavailable.'), chosen.join(', '))); return; } 341 342 var parts = [fmt(__('Selected: %s'), chosen.join(', '))]; 343 if (match.price_html) { parts.push(stripHtml(match.price_html)); } 344 parts.push(match.is_in_stock === false ? __('Out of stock') : __('In stock')); 345 announce(parts.join('. ') + '.'); 346 } 347 348 /* ââ Activation + keyboard âââââââââââââââââââââââââââââââââââââââââââââââ */ 349 function activate(group, select, sw, form, variations) { 350 var list = swatches(group); 351 setRoving(list, sw); 352 sw.focus(); 353 354 if (sw.getAttribute('aria-disabled') === 'true') { 355 announce(fmt(__('%s is unavailable with the current selection.'), swatchName(sw))); 356 return; 357 } 358 var value = sw.getAttribute('data-value'); 359 var already = sw.getAttribute('aria-checked') === 'true'; 360 commitValue(select, already ? '' : value); // re-activate clears (WC disables ATC) 361 syncGroup(group, select); // immediate reflect; observer re-confirms 362 announceSelection(form, variations); 363 } 364 365 function moveFocus(group, current, dir) { 366 var list = swatches(group); 367 if (!list.length) { return; } 368 var idx = 0; 369 for (var i = 0; i < list.length; i++) { if (list[i] === current) { idx = i; break; } } 370 var next; 371 if (dir === 'home') { next = 0; } 372 else if (dir === 'end') { next = list.length - 1; } 373 else { 374 next = idx + dir; 375 if (next < 0) { next = list.length - 1; } 376 if (next >= list.length) { next = 0; } 377 } 378 setRoving(list, list[next]); 379 list[next].focus(); 380 } 381 382 function wireGroup(group, select, form, variations) { 383 group.addEventListener('click', function (e) { 384 var sw = e.target.closest ? e.target.closest('.hzo-swatches__swatch') : null; 385 if (sw && group.contains(sw)) { activate(group, select, sw, form, variations); } 386 }); 387 group.addEventListener('keydown', function (e) { 388 var sw = e.target.closest ? e.target.closest('.hzo-swatches__swatch') : null; 389 if (!sw) { return; } 390 var k = e.key; 391 if (k === ' ' || k === 'Spacebar' || k === 'Enter') { 392 e.preventDefault(); 393 activate(group, select, sw, form, variations); 394 return; 395 } 396 var dir = null; 397 if (k === 'ArrowRight' || k === 'ArrowDown') { dir = 1; } 398 else if (k === 'ArrowLeft' || k === 'ArrowUp') { dir = -1; } 399 else if (k === 'Home') { dir = 'home'; } 400 else if (k === 'End') { dir = 'end'; } 401 if (dir === null) { return; } 402 e.preventDefault(); 403 moveFocus(group, sw, dir); 404 }); 405 } 406 407 // Mirror WC's option-disabling + programmatic resets without a jQuery dep. 408 function observeSelect(group, select) { 409 select.addEventListener('change', function () { syncGroup(group, select); }); 410 if (!window.MutationObserver) { return; } 411 var mo = new window.MutationObserver(function () { syncGroup(group, select); }); 412 mo.observe(select, { attributes: true, subtree: true, attributeFilter: ['disabled'], childList: true }); 413 } 414 415 /* ââ Per-form enhancement ââââââââââââââââââââââââââââââââââââââââââââââââ */ 416 function enhance(form, cfg) { 417 if (!form || form.getAttribute('data-hzo-swatches-ready') === 'true') { return; } 418 var selects = form.querySelectorAll('table.variations select, .variations select'); 419 if (!selects.length) { return; } 420 cfg = cfg || readConfig(); 421 form.setAttribute('data-hzo-swatches-ready', 'true'); 422 form.classList.add('hzo-swatches-active'); // CSS hides the native <select>s 423 424 var variations = parseVariations(form); 425 for (var i = 0; i < selects.length; i++) { 426 var select = selects[i]; 427 var group = buildGroup(select, cfg, variations); 428 if (!group) { continue; } 429 // Place the radiogroup right after the select inside its td.value. 430 if (select.parentNode) { select.parentNode.appendChild(group); } 431 wireGroup(group, select, form, variations); 432 observeSelect(group, select); 433 syncGroup(group, select); // initial checked + roving + label 434 } 435 } 436 437 /* ââ Init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 438 function init(scope) { 439 var root = scope || document; 440 var cfg = readConfig(); 441 var selector = cfg.form || FORM_SELECTOR; 442 var forms; 443 try { forms = root.querySelectorAll(selector); } 444 catch (e) { warn('invalid form selector "' + selector + '" â falling back to default.'); forms = root.querySelectorAll(FORM_SELECTOR); } 445 446 for (var i = 0; i < forms.length; i++) { enhance(forms[i], cfg); } 447 448 if (!forms.length && document.querySelector('.hzo-swatches-config[data-hzo-swatches]')) { 449 // Dev-warning convention §14 â the shortcode is on a page with no 450 // variable product for it to enhance. 451 warn('no WooCommerce variations_form found on this page â swatches have nothing to enhance.'); 452 } 453 } 454 455 if (document.readyState === 'loading') { 456 document.addEventListener('DOMContentLoaded', function () { init(); }); 457 } else { 458 init(); 459 } 460 461 // Shared namespace â enhance(form) for AJAX-injected forms (Quick-View W-132). 462 window.hzo = window.hzo || {}; 463 window.hzo.swatches = { init: init, enhance: enhance }; 464})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.