1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 READ-MORE CONTENT EXPANDER (W-092) â widget behavior · roadmap #14 3 4 Activates the WAI-ARIA APG Disclosure pattern on a .hzo-readmore block: a 5 real <button> toggle carrying aria-expanded + aria-controls that clamps the 6 full prose to a fade-masked preview and expands it back. The FULL text always 7 stays in the DOM â collapse is CSS overflow + max-block-size + a fade mask 8 (never display:none), so crawlers / AI / find-in-page / AT read all of it. 9 10 FAIL-VISIBLE: adding the .hzo-readmore--ready class is what enables the CSS 11 clamp AND reveals the toggle. Until this JS runs (or if it throws) the block 12 shows the full copy with no orphaned button â a stripped/missed engine is 13 benign, never a permanently-trapped-clamped block. 14 15 OPTION CONTRACT â data-hzo-readmore-* attributes on the .hzo-readmore root 16 (the [hzo_readmore] shortcode and the block pattern write the same attrs): 17 18 data-hzo-readmore-collapsed int lines | <length> (default 4 lines) 19 data-hzo-readmore-collapsed-sm override at <48em 20 data-hzo-readmore-collapsed-md override at 48-64em 21 data-hzo-readmore-collapsed-lg override at >=64em 22 data-hzo-readmore-fade false | <length> (mask off / depth) 23 data-hzo-readmore-default expanded (initial state) 24 data-hzo-readmore-scroll-on-collapse false (default on) 25 data-hzo-readmore-deep-link false (default on) 26 data-hzo-readmore-more / -less translated toggle labels 27 28 A11y: focus stays on the toggle across the state change; the chevron is 29 aria-hidden. Force-expand (instant, no anim) on focusin into clipped copy, 30 on :target / hashchange (deep link), and print is handled in CSS â so 31 keyboard / AT never land on visually-clipped content (PRESERVE-2). 32 33 Public API (shared namespace â never a new top-level global): 34 hzo.readmore = { init, setup } init(scope?) scans for .hzo-readmore; 35 setup(root) wires one block (AJAX content). 36 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 37(function () { 38 'use strict'; 39 40 window.hzo = window.hzo || {}; 41 42 var mm = window.matchMedia; 43 44 function reduceMotion() { 45 return !!(mm && mm('(prefers-reduced-motion: reduce)').matches); 46 } 47 48 function attr(root, name) { 49 var v = root.getAttribute('data-hzo-readmore-' + name); 50 return (v === null || v === '') ? null : v; 51 } 52 53 /* Translated toggle label â prefers the server-emitted (already localized) 54 data-attr, falls back to wp.i18n, then to English. */ 55 function label(root, which) { 56 var v = attr(root, which); 57 if (v) { return v; } 58 var en = (which === 'more') ? 'Read more' : 'Show less'; 59 if (window.wp && wp.i18n && typeof wp.i18n.__ === 'function') { 60 return wp.i18n.__(en, 'hozio-studio'); 61 } 62 return en; 63 } 64 65 /* Effective `collapsed` value for the current viewport (per-breakpoint 66 overrides win over the base). Recomputed on resize. */ 67 function effectiveCollapsed(root) { 68 if (mm) { 69 if (mm('(min-width: 64em)').matches && attr(root, 'collapsed-lg')) { 70 return attr(root, 'collapsed-lg'); 71 } 72 if (mm('(min-width: 48em)').matches && mm('(max-width: 63.9375em)').matches && attr(root, 'collapsed-md')) { 73 return attr(root, 'collapsed-md'); 74 } 75 if (mm('(max-width: 47.9375em)').matches && attr(root, 'collapsed-sm')) { 76 return attr(root, 'collapsed-sm'); 77 } 78 } 79 return attr(root, 'collapsed') || '4'; 80 } 81 82 /* Resolve a `collapsed` value to a CSS length string for the collapsed 83 height. Integer â lines * computed line-height (px). A length â verbatim. */ 84 function collapsedLength(content, val) { 85 val = String(val).trim(); 86 if (/^\d+$/.test(val)) { 87 var cs = getComputedStyle(content); 88 var lh = parseFloat(cs.lineHeight); 89 if (isNaN(lh)) { lh = (parseFloat(cs.fontSize) || 16) * 1.6; } 90 return (parseInt(val, 10) * lh) + 'px'; 91 } 92 return val; 93 } 94 95 /* Push the resting collapsed height into the CSS var the clamp rule reads, 96 so clearing the inline max-block-size after an animation settles onto the 97 exact same height (no jump). */ 98 function syncCollapsedVar(root, content) { 99 root.style.setProperty('--hzo-readmore-max', collapsedLength(content, effectiveCollapsed(root))); 100 } 101 102 /* The collapsed height as a plain px number (for the fits-check). Integer â 103 lines * line-height; a px/rem/em length â resolved to px. NaN if unparseable 104 (â arm anyway, the safe default). */ 105 function collapsedPxNum(root, content) { 106 var raw = String(effectiveCollapsed(root)).trim(); 107 var cs = getComputedStyle(content); 108 if (/^\d+$/.test(raw)) { 109 var lh = parseFloat(cs.lineHeight); 110 if (isNaN(lh)) { lh = (parseFloat(cs.fontSize) || 16) * 1.6; } 111 return parseInt(raw, 10) * lh; 112 } 113 var m = raw.match(/^([0-9]+(?:\.[0-9]+)?)(px|rem|em)$/); 114 if (!m) { return NaN; } 115 var n = parseFloat(m[1]); 116 if (m[2] === 'px') { return n; } 117 if (m[2] === 'rem') { return n * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16); } 118 return n * (parseFloat(cs.fontSize) || 16); // em 119 } 120 121 function isCollapsed(root) { 122 return !root.classList.contains('is-expanded'); 123 } 124 125 /* ââ Expand / collapse. Animates max-block-size between the collapsed height 126 and the measured content height; reduced-motion skips the animation. ââ */ 127 function setLabel(root, expanded) { 128 var span = root.querySelector('.hzo-readmore__label');
129 if (span) { span.textContent = expanded ? label(root, 'less') : label(root, 'more'); } 130 var btn = root.querySelector('.hzo-readmore__toggle'); 131 if (btn) { btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } 132 } 133 134 function afterTransition(content, done) { 135 var fired = false; 136 function handler(e) { 137 if (e && e.propertyName && e.propertyName.indexOf('max') === -1) { return; } 138 if (fired) { return; } 139 fired = true; 140 content.removeEventListener('transitionend', handler); 141 done(); 142 } 143 content.addEventListener('transitionend', handler); 144 // Fallback in case transitionend never fires (interrupted, display swap). 145 setTimeout(function () { if (!fired) { fired = true; content.removeEventListener('transitionend', handler); done(); } }, 600); 146 } 147 148 function expand(root) { 149 var content = root.querySelector('.hzo-readmore__content'); 150 if (!content || root.classList.contains('is-expanded')) { return; } 151 152 if (reduceMotion()) { 153 root.classList.add('is-expanded'); 154 content.style.maxBlockSize = ''; 155 setLabel(root, true); 156 return; 157 } 158 // From: current clamped height â To: full content height, then release. 159 content.style.maxBlockSize = content.offsetHeight + 'px'; 160 root.classList.add('is-expanded'); // drops the mask immediately 161 setLabel(root, true); 162 // Force reflow so the from-height is committed before the to-height. 163 void content.offsetHeight; 164 content.style.maxBlockSize = content.scrollHeight + 'px'; 165 afterTransition(content, function () { 166 if (root.classList.contains('is-expanded')) { content.style.maxBlockSize = ''; } 167 }); 168 } 169 170 function collapse(root) { 171 var content = root.querySelector('.hzo-readmore__content'); 172 if (!content || !root.classList.contains('is-expanded')) { return; } 173 174 syncCollapsedVar(root, content); 175 var target = collapsedLength(content, effectiveCollapsed(root)); 176 177 if (reduceMotion()) { 178 root.classList.remove('is-expanded'); 179 content.style.maxBlockSize = ''; // CSS var rule takes over 180 setLabel(root, false); 181 maybeScrollIntoView(root); 182 return; 183 } 184 // From: full height â To: collapsed height, then release to the var rule. 185 content.style.maxBlockSize = content.offsetHeight + 'px'; 186 void content.offsetHeight; // commit the from-height 187 root.classList.remove('is-expanded'); // restores the mask 188 setLabel(root, false); 189 content.style.maxBlockSize = target; 190 afterTransition(content, function () { 191 if (!root.classList.contains('is-expanded')) { content.style.maxBlockSize = ''; } 192 maybeScrollIntoView(root); 193 }); 194 } 195 196 function toggle(root) { 197 if (isCollapsed(root)) { expand(root); } else { collapse(root); } 198 } 199 200 /* On collapse, bring the block top back into view if it scrolled above the 201 viewport (so the reader isn't stranded mid-page). Off when opted out. */ 202 function maybeScrollIntoView(root) { 203 if (attr(root, 'scroll-on-collapse') === 'false') { return; } 204 var rect = root.getBoundingClientRect(); 205 if (rect.top >= 0) { return; } // top already visible â don't fight the viewport 206 try { 207 root.scrollIntoView({ block: 'start', behavior: reduceMotion() ? 'auto' : 'smooth' }); 208 } catch (e) { 209 root.scrollIntoView(); 210 } 211 } 212 213 /* Instant expand (no animation) â the fail-visible primitive for focus / 214 deep-link so a reader is never stranded on clipped copy. */ 215 function forceExpand(root) { 216 if (!root || root.classList.contains('is-expanded')) { return; } 217 var content = root.querySelector('.hzo-readmore__content'); 218 root.classList.add('is-expanded'); 219 if (content) { content.style.maxBlockSize = ''; } 220 setLabel(root, true); 221 } 222 223 function setup(root) { 224 if (!root || !root.classList || root.classList.contains('hzo-readmore--ready')) { return; } // idempotent 225 var content = root.querySelector('.hzo-readmore__content'); 226 var btn = root.querySelector('.hzo-readmore__toggle'); 227 if (!content || !btn) { return; } 228 229 // Content that already fits the collapsed height needs NO toggle â a 230 // clamp there is a no-op and a toggle a dead control (§States / G22). Leave 231 // the block un-armed (full copy shows, the actions row stays CSS-hidden
231) 232 // and warn so the author knows the clamp does nothing here (§14/G57). The 233 // data-fit flag also makes this outcome idempotent across re-inits. 234 var capPx = collapsedPxNum(root, content); 235 if (!isNaN(capPx) && content.scrollHeight <= capPx + 1) { 236 root.setAttribute('data-hzo-readmore-fit', 'true'); 237 if (window.console && console.warn) { 238 console.warn('[hzo_readmore] content fits within collapsed height â no toggle rendered', root); 239 } 240 return; 241 } 242 243 // Prime the resting collapsed height, then arm: the .hzo-readmore--ready 244 // class enables the CSS clamp + reveals the toggle. 245 syncCollapsedVar(root, content); 246 root.classList.add('hzo-readmore--ready'); 247 setLabel(root, !isCollapsed(root)); 248 249 btn.addEventListener('click', function () { toggle(root); }); 250 251 // Recompute the resting collapsed height on resize (per-breakpoint + 252 // reflowed line-height). Only meaningful while collapsed. 253 if ('ResizeObserver' in window) { 254 var ro = new ResizeObserver(function () { 255 if (isCollapsed(root)) { syncCollapsedVar(root, content); } 256 }); 257 ro.observe(root); 258 } 259 } 260 261 function init(scope) { 262 var roots = (scope || document).querySelectorAll('.hzo-readmore:not(.hzo-readmore--ready):not([data-hzo-readmore-fit])'); 263 for (var i = 0; i < roots.length; i++) { setup(roots[i]); } 264 } 265 266 /* ââ Force-expand listeners (content-preserving guarantee) ââ */ 267 268 // Keyboard / AT lands on a link inside clipped copy â expand its block. 269 document.addEventListener('focusin', function (e) { 270 var content = e.target && e.target.closest ? e.target.closest('.hzo-readmore__content') : null; 271 if (!content) { return; } 272 var root = content.closest('.hzo-readmore'); 273 if (root && isCollapsed(root)) { forceExpand(root); } 274 }); 275 276 // Deep link: a #hash into the region (or an element inside it) force-expands. 277 function revealHashTarget() { 278 if (!location.hash || location.hash.length < 2) { return; } 279 var t; 280 try { t = document.getElementById(location.hash.slice(1)); } catch (e) { t = null; } 281 if (!t) { return; } 282 var root = t.classList && t.classList.contains('hzo-readmore') ? t : t.closest('.hzo-readmore'); 283 if (root && attr(root, 'deep-link') !== 'false' && isCollapsed(root)) { forceExpand(root); } 284 } 285 window.addEventListener('hashchange', revealHashTarget); 286 287 function boot() { 288 try { init(); revealHashTarget(); } 289 catch (e) { 290 if (window.console && console.warn) { console.warn('[hzo_readmore] init failed â content left visible: ' + e); } 291 } 292 } 293 if (document.readyState === 'loading') { 294 document.addEventListener('DOMContentLoaded', boot); 295 } else { 296 boot(); 297 } 298 299 // Shared engine namespace (widget convention) â never a new top-level global. 300 window.hzo.readmore = { init: init, setup: setup }; 301})();
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.