PageSourceSearch

https://www.tomkadlec.com/app/themes/dv-framework/media/js/da-flyout.js?v=1.789.531

js tomkadlec.com collected 2026-10-01 15:39:40 UTC 21,967 bytes, 537 lines download raw bytes

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.