1/** 2 * @DealerAlchemist Flyout / Dropdown â DAFlyOut, DADropdown 3 * 4 * Expandable panels inside the pricing stacks Nexus compiles into `pricing_html`. 5 * 6 * DAFlyOut â an info icon on a price row, opening a small panel (a disclaimer, or a 7 * per-program breakdown). Replaces DVPopUp for pricing disclaimers: DVPopUp is a 8 * page-level modal, and a full-screen takeover to read twelve words of MSRP 9 * disclaimer was never the right shape. 10 * DADropdown â a full-width labelled bar that expands a list, e.g. "Up To $3,500 In Available 11 * Incentives". Replaces the hand-written `ag-conditional-toggle` block. 12 * 13 * PORT. The canonical implementation lives in the Nexus repo at 14 * `libs/ui-kit/src/templates/srp/lib/daFlyout.ts` and is covered by `daFlyout.test.ts`. Keep the 15 * two in step: both are driven by the same hand-authored markup, and that markup is the contract 16 * between them. The fixtures in daFlyout.test.ts are the reference to copy from. 17 * 18 * WHY THIS EXISTS. Pricing stacks used to carry ~250 lines of inline `<script>` and `<style>` per 19 * dealer, per condition, pasted into an HTML box, purely to make these panels expand. Every copy 20 * drifted. This is that behaviour written once, called inline the way DVPopUp(this) already is: 21 * 22 * <i class="da-flyout-trigger" onclick="DAFlyOut(this); return false;"></i> 23 * <button class="da-dropdown-toggle" onclick="DADropdown(this); return false;"> 24 * 25 * Loaded globally from header.php beside da-modal.js, so legacy SRP, studio SRP and the VDP all 26 * get it without each template asking. 27 * 28 * PANELS OVERLAY, THEY DO NOT GROW THE CARD. 29 * 30 * These panels open ON TOP of the page. The card keeps its height, nothing reflows, and the panel 31 * is free to be taller than the space under the row. 32 * 33 * Getting there needs more than `position:absolute; z-index:999` â that is what the legacy inline 34 * scripts did, and it is why the conditional-incentives bar rendered badly. `.srp-card-face` 35 * (srp-studio.css) sets `transform: rotateY(0deg)` and `overflow:hidden` for the flip animation, 36 * and a transformed ancestor does three things at once: 37 * 38 * 1. becomes the containing block for absolutely-positioned descendants â so `absolute` resolves 39 * against the card, not the page, 40 * 2. becomes the containing block for `position:fixed` descendants too â so `fixed` does not 41 * escape either, 42 * 3. opens a new stacking context â so no `z-index`, however large, can lift the panel above 43 * anything outside the card, 44 * 45 * and `overflow:hidden` then clips whatever still hangs outside. No amount of CSS on the panel 46 * fixes that while the panel remains a descendant of the card. 47 * 48 * So the panel LEAVES the card while it is open: on open it is moved to `document.body` (a comment 49 * node holds its place) and positioned against its row with `position:fixed` coordinates taken from 50 * `getBoundingClientRect()`; on close it is put back. Where the browser supports the Popover API the 51 * panel is also promoted to the top layer, which puts it above every stacking context on the page 52 * for free. Both paths are re-measured on scroll and resize so the panel stays tethered to its row. 53 * 54 * Because an open panel is no longer inside its wrapper, `wrapper.querySelector(panel)` cannot find 55 * it â open panels are tracked in `openPanels` instead, and that registry is the lookup while open. 56 * 57 * Styles are injected from here rather than enqueued separately: one file cannot go missing while 58 * the other loads, so a stack can never render half-styled. 59 */ 60(function (window, document) { 61 "use strict"; 62 63 /* Exported BEFORE the re-entry guard, not after it. 64 ** 65 ** These are the entire public surface: the markup calls them inline as `onclick="DAFlyOut(this)"`, 66 ** so a page where the guard fired but the assignment never ran is a page where every trigger 67 ** throws "DAFlyOut is not defined". Assigning first means a second copy of this file bails out 68 ** of re-registering listeners while still leaving working globals behind, whichever copy won. 69 ** Function declarations hoist, so all three are defined at this point. */ 70 window.DAFlyOut = DAFlyOut; 71 window.DADropdown = DADropdown; 72 window.DAFlyOutCloseAll = closeAllPanels; 73 74 if (window.daFlyoutInitialized) return; 75 window.daFlyoutInitialized = true; 76 77 /* Duration of the open/close transition, kept in step with the injected CSS. */ 78 var ANIMATION_MS = 250; 79 80 var STYLE_ELEMENT_ID = "da-flyout-styles"; 81 82 /* Gap between the row and its panel, and the minimum breathing room against the viewport edge. */ 83 var PANEL_GAP = 8; 84 var VIEWPORT_MARGIN = 8; 85 var MIN_PANEL_WIDTH = 220; 86 var MAX_PANEL_WIDTH = 380; 87 88 var OVERLAY_CLASS = "da-panel-overlay"; 89 var VISIBLE_CLASS = "da-panel-visible"; 90 91 /* 92 ** `anchor` names the element the panel is measured against: the whole price row for a flyout, 93 ** the toggle button for a dropdown, so each panel lines up with something the width of itself. 94 */ 95 var FLYOUT = { 96 wrapper: "[data-da-flyout]", 97 panel: "[data-da-flyout-panel]", 98 trigger: ".da-flyout-trigger", 99 openClass: "da-flyout-open", 100 anchor: "wrapper" 101 }; 102 103 var DROPDOWN = { 104 wrapper: "[data-da-dropdown]", 105 panel: "[data-da-dropdown-panel]", 106 trigger: ".da-dropdown-toggle", 107 openClass: "da-dropdown-open", 108 anchor: "trigger" 109 }; 110 111 var KINDS = [FLYOUT, DROPDOWN]; 112 113 /* Every panel currently detached into document.body, with what it needs to get home again. */ 114 var openPanels = []; 115 116 var supportsPopover = typeof HTMLElement !== "undefined" 117 && typeof HTMLElement.prototype.showPopover === "function"; 118 119 var STYLES = [ 120 /* ââ Flyout: an info icon on a price row âââââââââââââââââââââââ */ 121 /* A trigger is authored as a bare <i> and gets its glyph from icon-font classes 122 ("fad fa-info-circle da-flyout-trigger"). Templates that omit those classes leave an 123 EMPTY inline element: 0x0, invisible, and impossible to click - the panel markup is 124 perfect and the flyout simply looks broken. So the trigger carries its own box, and 125 draws a fallback badge when no icon font is going to fill it. The :not() keeps every 126 template that does ship fa-* classes pixel-identical to before. */ 127 ".da-flyout-trigger { cursor: pointer; margin-left: 4px; color: inherit; }", 128 /* Everything below is scoped to the no-icon-font case so a trigger that DOES carry 129 fa-* classes keeps Font Awesome's own box untouched - no width/height/line-height 130 imposed on a glyph that sizes itself. */ 131 ".da-flyout-trigger:not([class*=\"fa-\"]) {", 132 "\tposition: relative; display: inline-block; box-sizing: border-box;", 133 "\twidth: 1em; height: 1em; line-height: 1; vertical-align: -.125em;", 134 "\tborder: 1px solid currentColor; border-radius: 50%;", 135 "\topacity: .65; font-style: normal;", 136 "}", 137 ".da-flyout-trigger:not([class*=\"fa-\"])::before {", 138 "\tcontent: \"i\"; position: absolute; inset: 0;", 139 "\tdisplay: flex; align-items: center; justify-content: center;", 140 "\tfont-size: .7em; font-weight: 700; font-family: Georgia, \"Times New Roman\", serif;", 141 "}", 142 ".da-flyout-trigger:not([class*=\"fa-\"]):hover { opacity: 1; }", 143 ".da-flyout-trigger:focus-visible,", 144 ".da-dropdown-toggle:focus-visible { outline: 2px solid rgba(0,0,0,.18); outline-offset: 2px; border-radius: 2px; }", 145 146 /* Closed is the only in-flow state. Panels are authored with `hidden`, so with neither CSS 147 nor JS they stay collapsed rather than dumping every disclaimer into the card. Keyed on 148 [hidden] rather than the class alone so it cannot tie with the overlay rule below. */ 149 ".da-flyout-panel[hidden], .da-dropdown-panel[hidden] { display: none; }", 150 151 /* ââ The open panel, detached into document.body âââââââââââââ 152 `inset`, `margin`, `width`, `height` and `padding` are reset explicitly: the UA stylesheet 153 for [popover] centres it with `inset:0; margin:auto` and adds its own border and padding, 154 which would fight the coordinates set in JS. */ 155 "." + OVERLAY_CLASS + " {", 156 " display: block; position: fixed; inset: auto; margin: 0; padding: 0;", 157 " width: auto; height: auto; max-height: 60vh; overflow-y: auto; overscroll-behavior: contain;", 158 " box-sizing: border-box; text-align: left;", 159 " background: #fff; border: 1px solid rgba(0,0,0,.10); border-radius: 10px;", 160 " box-shadow: 0 12px 32px rgba(0,0,0,.18);", 161 /* Only needed on the fallback path; a top-layer popover is already above everything. */
162 " z-index: 2147483000;", 163 " opacity: 0; transform: translateY(4px);", 164 " transition: opacity " + ANIMATION_MS + "ms ease, transform " + ANIMATION_MS + "ms ease;", 165 "}", 166 "." + OVERLAY_CLASS + "." + VISIBLE_CLASS + " { opacity: 1; transform: translateY(0); }", 167 "." + OVERLAY_CLASS + "::backdrop { background: transparent; }", 168 169 ".da-flyout-panel-inner, .da-dropdown-panel-inner { padding: 10px 12px; }", 170 ".da-flyout-note { margin: 0; font-size: .75rem; line-height: 1.35; color: #555; font-weight: 400; }", 171 172 /* ââ Dropdown bar ââââââââââââââââââââââââââââââââââââââââââââââ */ 173 ".da-dropdown { width: 100%; position: relative; }", 174 ".da-dropdown-toggle {", 175 " display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 6px;", 176 " padding: 7px 8px; border: none; border-radius: 6px; cursor: pointer; text-align: left;", 177 " box-sizing: border-box; font-size: 13px; font-weight: 400; line-height: 1.2;", 178 " transition: filter " + ANIMATION_MS + "ms ease;", 179 "}", 180 ".da-dropdown-toggle:hover { filter: brightness(.95); }", 181 ".da-dropdown-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }", 182 ".da-dropdown-chevron {", 183 " width: 6px; height: 6px; flex-shrink: 0;", 184 " border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;", 185 " transform: rotate(45deg); transition: transform " + ANIMATION_MS + "ms ease;", 186 "}", 187 ".da-dropdown-open .da-dropdown-chevron { transform: rotate(-135deg); }", 188 189 /* ââ Panel line items, shared by both ââââââââââââââââââââââââââ */ 190 ".da-flyout-item, .da-dropdown-item {", 191 " display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: start;", 192 " column-gap: 10px; padding: 8px 0; line-height: 1.25;", 193 "}", 194 ".da-flyout-item:not(:last-child),", 195 ".da-dropdown-item:not(:last-child) { border-bottom: 1px dashed #d7d7d7; }", 196 ".da-flyout-item-title, .da-dropdown-item-title {", 197 " display: block; font-size: .8rem; font-weight: 700; color: #222;", 198 "}", 199 ".da-flyout-item-disclaimer, .da-dropdown-item-disclaimer {", 200 " display: block; margin-top: 3px; font-size: .625rem; line-height: 1.25; color: #666;", 201 " font-weight: 400; white-space: normal; overflow-wrap: anywhere;", 202 "}", 203 ".da-flyout-item-value, .da-dropdown-item-value {", 204 " font-size: .8rem; font-weight: 700; white-space: nowrap; align-self: start;", 205 "}", 206 ".da-flyout-empty, .da-dropdown-empty { padding: 8px 0; font-size: .75rem; color: #666; }", 207 208 /* Legacy themes set .dv-price-stack p { height: 23px } and size every descendant span, which 209 squashes panel contents. Mostly moot now the open panel sits in body and those descendant 210 selectors no longer match it, but kept so a panel still reads correctly before it opens. */ 211 ".da-flyout-panel p, .da-dropdown-panel p { height: auto !important; min-height: 0; }", 212 ".da-flyout-panel span, .da-dropdown-panel span { font-size: inherit; }", 213 214 /* ââ Price slash âââââââââââââââââââââââââââââââââââââââââââââ 215 The tapered red diagonal that used to be media/img/slash-1.png. Drawn in CSS because 216 that src is a theme-absolute path: it 404s in the Nexus dashboard studio and the React 217 client, and its positioning lives in theme CSS neither of them loads, so it rendered 218 full-size and shoved the row apart. The gradient reproduces the tapered ends. */ 219 ".da-price-slash { position: relative; }", 220 ".da-price-slash::after {", 221 " content: \"\"; position: absolute; left: -6%; right: -6%; top: 50%; height: 2px;", 222 " background: linear-gradient(to right, transparent, #E8112D 12%, #E8112D 88%, transparent);", 223 " transform: translateY(-50%) rotate(-11deg); pointer-events: none;", 224 "}", 225 226 /* ââ Legacy slash images, React contexts only ââââââââââââââââ 227 .srp-pricing-container exists only in the Nexus React renderers, never in this theme, 228 so these rules are inert here and live WordPress pages keep the real image. Carried in 229 the shared file purely to keep it identical to its Nexus counterpart. */ 230 ".srp-pricing-container .dv-price-stack p { position: relative; }", 231 ".srp-pricing-container img.dv-price-slash,", 232 ".srp-pricing-container img.da-price-slash {", 233 " position: absolute; right: 0; top: 50%; width: 63px; height: 2px; max-width: 63px;", 234 " overflow: hidden; text-indent: -9999px; color: transparent;", 235 " background: linear-gradient(to right, transparent, #E8112D 12%, #E8112D 88%, transparent);", 236 " transform: translateY(-50%) rotate(-11deg); pointer-events: none;", 237 "}", 238 239 /* Convenience for templates that hide a line on small screens
239without writing their own CSS. */ 240 "@media (max-width: 639px) { .da-price-row.da-hide-mobile { display: none !important; } }", 241 "@media (prefers-reduced-motion: reduce) {", 242 " ." + OVERLAY_CLASS + ", .da-dropdown-chevron { transition: none; }", 243 "}" 244 ].join("\n"); 245 246 function injectStyles() { 247 if (document.getElementById(STYLE_ELEMENT_ID)) return; 248 249 var style = document.createElement("style"); 250 style.id = STYLE_ELEMENT_ID; 251 style.textContent = STYLES; 252 document.head.appendChild(style); 253 } 254 255 function findOpen(wrapper) { 256 for (var i = 0; i < openPanels.length; i++) { 257 if (openPanels[i].wrapper === wrapper) return openPanels[i]; 258 } 259 260 return null; 261 } 262 263 function resolveParts(element, kind) { 264 if (!element || typeof element.closest !== "function") return null; 265 266 var wrapper = element.closest(kind.wrapper); 267 if (!wrapper) return null; 268 269 /* An open panel has been moved out of its wrapper, so the registry is the only way back. */ 270 var open = findOpen(wrapper); 271 if (open) return open; 272 273 var panel = wrapper.querySelector(kind.panel); 274 if (!panel) return null; 275 276 var trigger = wrapper.querySelector(kind.trigger); 277 278 return { 279 kind: kind, 280 wrapper: wrapper, 281 panel: panel, 282 trigger: trigger, 283 anchor: (kind.anchor === "trigger" && trigger) ? trigger : wrapper, 284 placeholder: null 285 }; 286 } 287 288 function isOpen(parts) { 289 return parts.wrapper.classList.contains(parts.kind.openClass); 290 } 291 292 /* Measure the panel against its row and pin it, flipping above when there is no room below. */ 293 function positionPanel(parts) { 294 var panel = parts.panel; 295 var rect = parts.anchor.getBoundingClientRect(); 296 var available = Math.min(MAX_PANEL_WIDTH, window.innerWidth - (VIEWPORT_MARGIN * 2)); 297 var width = Math.max(Math.min(rect.width, available), Math.min(MIN_PANEL_WIDTH, available)); 298 299 panel.style.width = width + "px"; 300 301 var height = panel.offsetHeight; 302 var roomBelow = window.innerHeight - rect.bottom - PANEL_GAP; 303 var roomAbove = rect.top - PANEL_GAP; 304 var placeAbove = roomBelow < height && roomAbove > roomBelow; 305 var top = placeAbove ? rect.top - height - PANEL_GAP : rect.bottom + PANEL_GAP; 306 var left = rect.left; 307 308 /* Keep it on screen even when the row sits hard against an edge. */ 309 top = Math.max(VIEWPORT_MARGIN, Math.min(top, window.innerHeight - height - VIEWPORT_MARGIN)); 310 left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN)); 311 312 panel.style.top = top + "px"; 313 panel.style.left = left + "px"; 314 } 315 316 /* 317 ** Move the panel to document.body, leaving a comment node to mark where it came from. This is 318 ** what escapes the card's overflow, stacking context and containing block all at once. 319 */ 320 function detachPanel(parts) { 321 var panel = parts.panel; 322 323 if (panel.parentNode) { 324 parts.placeholder = document.createComment("da-panel"); 325 panel.parentNode.insertBefore(parts.placeholder, panel); 326 } 327 328 document.body.appendChild(panel); 329 panel.classList.add(OVERLAY_CLASS); 330 331 /* The top layer sits above every stacking context, so the fallback z-index is not needed. */ 332 if (supportsPopover) { 333 try { 334 panel.setAttribute("popover", "manual"); 335 panel.showPopover(); 336 } catch (error) { 337 panel.removeAttribute("popover"); 338 } 339 } 340 } 341 342 /* Put the panel back where it came from, or drop it if its card was re-rendered meanwhile. */ 343 function reattachPanel(parts) { 344 var panel = parts.panel; 345 346 if (supportsPopover && panel.hasAttribute("popover")) { 347 try { 348 panel.hidePopover(); 349 } catch (error) { 350 /* Already hidden, e.g. the element left the document first. */ 351 } 352 353 panel.removeAttribute("popover"); 354 } 355 356 panel.classList.remove(OVERLAY_CLASS, VISIBLE_CLASS); 357 panel.style.top = ""; 358 panel.style.left = ""; 359 panel.style.width = ""; 360 361 var placeholder = parts.placeholder; 362 363 if (placeholder && placeholder.parentNode) { 364 placeholder.parentNode.insertBefore(panel, placeholder); 365 placeholder.parentNode.removeChild(placeholder); 366 } else if (panel.parentNode === document.body) { 367 /* The card went away under us - InstantSearch re-rendered the hit. Do not orphan it. */ 368 document.body.removeChild(panel); 369 } 370 371 parts.placeholder = null; 372 } 373
374 function openPanel(parts) { 375 if (isOpen(parts)) return; 376 377 closeAllPanels(parts.wrapper); 378 379 /* Unhide first: an element that is still display:none cannot be measured or animated. */ 380 parts.panel.hidden = false; 381 detachPanel(parts); 382 openPanels.push(parts); 383 positionPanel(parts); 384 385 requestAnimationFrame(function () { 386 requestAnimationFrame(function () { 387 parts.wrapper.classList.add(parts.kind.openClass); 388 parts.panel.classList.add(VISIBLE_CLASS); 389 if (parts.trigger) parts.trigger.setAttribute("aria-expanded", "true"); 390 }); 391 }); 392 } 393 394 function closePanel(parts) { 395 if (!isOpen(parts)) return; 396 397 parts.wrapper.classList.remove(parts.kind.openClass); 398 parts.panel.classList.remove(VISIBLE_CLASS); 399 if (parts.trigger) parts.trigger.setAttribute("aria-expanded", "false"); 400 401 var index = openPanels.indexOf(parts); 402 if (index > -1) openPanels.splice(index, 1); 403 404 /* Reattach only once the fade has run, and only if nothing reopened it meanwhile. */ 405 setTimeout(function () { 406 if (isOpen(parts)) return; 407 408 reattachPanel(parts); 409 parts.panel.hidden = true; 410 }, ANIMATION_MS); 411 } 412 413 /** 414 * Close every open panel. 415 * @param {Element} [except] Wrapper to leave open. 416 */ 417 function closeAllPanels(except) { 418 openPanels.slice().forEach(function (parts) { 419 if (parts.wrapper === except) return; 420 421 closePanel(parts); 422 }); 423 } 424 425 function toggle(element, kind) { 426 var parts = resolveParts(element, kind); 427 if (!parts) return; 428 429 if (isOpen(parts)) { 430 closePanel(parts); 431 } else { 432 openPanel(parts); 433 } 434 } 435 436 /** 437 * Toggle the flyout owning `element`. 438 * @param {Element} element Any node inside the flyout row, normally the trigger itself. 439 */ 440 function DAFlyOut(element) { 441 toggle(element, FLYOUT); 442 } 443 444 /** 445 * Toggle the dropdown owning `element`. 446 * @param {Element} element Any node inside the dropdown, normally the toggle button. 447 */ 448 function DADropdown(element) { 449 toggle(element, DROPDOWN); 450 } 451 452 injectStyles(); 453 454 /* 455 ** A pinned panel has to keep up with its row. Repositioning is rAF-throttled and runs on capture 456 ** so it catches scrolling in the SRP results pane, not just the window. 457 */ 458 var repositionQueued = false; 459 460 function reposition() { 461 repositionQueued = false; 462 463 openPanels.slice().forEach(function (parts) { 464 /* The card was re-rendered or removed - close rather than leave a panel floating. 465 closePanel's timeout does the reattach, which finds no placeholder and drops it. */ 466 if (!document.contains(parts.wrapper)) { 467 closePanel(parts); 468 return; 469 } 470 471 positionPanel(parts); 472 }); 473 } 474 475 function queueReposition() { 476 if (repositionQueued || openPanels.length === 0) return; 477 478 repositionQueued = true; 479 requestAnimationFrame(reposition); 480 } 481 482 window.addEventListener("scroll", queueReposition, true); 483 window.addEventListener("resize", queueReposition); 484 485 /* Outside click. Triggers stop here first via their inline `return false`. */ 486 document.addEventListener("click", function (event) { 487 var target = event.target; 488 if (!target || typeof target.closest !== "function") return; 489 490 var onTrigger = KINDS.some(function (kind) { return target.closest(kind.trigger); }); 491 if (onTrigger) return; 492 493 /* An open panel lives in body, so "inside" means inside the panel or inside its wrapper. */ 494 var panel = target.closest("." + OVERLAY_CLASS); 495 496 if (panel) { 497 for (var i = 0; i < openPanels.length; i++) { 498 if (openPanels[i].panel === panel) { 499 closeAllPanels(openPanels[i].wrapper); 500 return; 501 } 502 } 503 } 504 505 var inside = null; 506 KINDS.forEach(function (kind) { 507 inside = inside || target.closest(kind.wrapper + "." + kind.openClass); 508 }); 509 510 closeAllPanels(inside || undefined); 511 }); 512 513 document.addEventListener("keydown", function (event) { 514 if (event.key === "Escape") { 515 closeAllPanels(); 516 return; 517 } 518 519 if (event.key !== "Enter" && event.key !== " ") return; 520 521 var target = event.target; 522 if (!target || typeof target.closest !== "function") return; 523 524 for (var i = 0; i < KINDS.length; i++) { 525 var trigger = target.closest(KINDS[i].trigger); 526 if (!trigger) continue; 527 528 /* A <button> toggle already activates natively on Enter/Space; an <i role="button"> does not. */ 529 if (trigger.tagName === "BUTTON") return; 530 531 event.preventDefault(); 532 toggle(trigger, KINDS[i]); 533 return; 534 } 535 }); 536 537})(window, document);
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.