/**
* scroll-popups-hardened.js
* Hides chat/feedback popups on scroll down; shows on scroll up.
* Robust to late-loaded widgets, shadow DOM, and (to a degree) cross-origin iframes.
*/
const SELECTORS = [
'.pagetop',
'#nespresso-conversational-window',
'#QSIFeedbackButton-btn',
// Add any iframe wrappers you recognize below (examples):
// 'iframe[src*="qualtrics"]',
// 'iframe[src*="konnektive"]',
];
const ROOT_TOGGLE_CLASS = 'popups-hidden';
const MIN_DELTA = 8;
const ENABLE_ON_ALL_DEVICES = true; // set false to limit to mobile-like only
// ----- CSS (define visible + hidden, applied via class on ) -----
function ensureHiddenStyle() {
const id = `scroll-popups-style-${ROOT_TOGGLE_CLASS}`;
if (document.getElementById(id)) return;
const s = document.createElement('style');
s.id = id;
s.textContent = `
/* default visible state with transitions so late elements animate correctly */
${SELECTORS.join(', ')} {
opacity: 1;
transform: translateY(0);
visibility: visible;
transition: opacity 200ms ease, transform 200ms ease, visibility 200ms;
will-change: opacity, transform;
}
/* hidden state driven by a root toggle class so late-loaded nodes are affected automatically */
html.${ROOT_TOGGLE_CLASS} ${SELECTORS.join(', ')} {
opacity: 0 !important;
transform: translateY(14px) !important;
pointer-events: none !important;
visibility: hidden !important;
}
`;
document.head.appendChild(s);
}
// ----- Core decision (pure) -----
function decideVisibilityAction(prevY, currY, minDelta = MIN_DELTA) {
if (!Number.isFinite(prevY) || !Number.isFinite(currY)) return 'none';
const d = currY - prevY; // >0 when scrolling DOWN
if (Math.abs(d) < minDelta) return 'none';
if (currY <= 0) return 'show';
const doc = document.scrollingElement || document.documentElement;
if (doc && doc.scrollHeight - doc.clientHeight - currY < 4) return 'show';
return d > 0 ? 'hide' : 'show';
}
function isMobileLike() {
try {
if (window.matchMedia?.('(pointer: coarse)').matches) return true;
} catch {}
return window.innerWidth <= 768;
}
// ----- Visibility application -----
// We toggle a class on for CSS, and keep aria-hidden in sync on matching nodes, including late ones.
function setHiddenState(hidden) {
document.documentElement.classList.toggle(ROOT_TOGGLE_CLASS, hidden);
const aria = hidden ? 'true' : 'false';
// update current matches (light DOM + any open shadow roots we’re tracking)
queryAllDeep(SELECTORS).forEach((el) => {
try { el.setAttribute('aria-hidden', aria); } catch {}
});
}
function applyVisibility(action) {
if (action === 'hide') setHiddenState(true);
else if (action === 'show') setHiddenState(false);
}
// ----- Deep querying across light DOM + open shadow roots -----
// We maintain a small registry of open shadow roots to search as they appear.
const shadowRoots = new Set(); // Set
function queryAllDeep(selectors) {
// Search light DOM
const results = new Set([...document.querySelectorAll(selectors.join(','))]);
// Search tracked shadow roots
for (const root of shadowRoots) {
try {
root.host && results.add(root.host.matches && selectors.some((s) => root.host.matches(s)) ? root.host : null);
root.querySelectorAll && root.querySelectorAll(selectors.join(',')).forEach((el) => results.add(el));
} catch {}
}
// Remove potential nulls
results.delete(null);
return [...results];
}
// Track any open shadow roots dynamically
function tryTrackShadowRoot(node) {
// If an element exposes an *open* shadowRoot, track it
if (node && node.shadowRoot && node.shadowRoot.mode === 'open') {
if (!shadowRoots.has(node.shadowRoot)) {
shadowRoots.add(node.shadowRoot);
// Also observe inside it for nested additions
observer.observe(node.shadowRoot, { childList: true, subtree: true });
}
}
}
// ----- MutationObserver (light DOM + open shadow roots) -----
const observer = new MutationObserver((mutations) => {
const hidden = document.documentElement.classList.contains(ROOT_TOGGLE_CLASS);
const aria = hidden ? 'true' : 'false';
for (const m of mutations) {
// Newly added nodes: sync aria, discover shadow roots
m.addedNodes.forEach((n) => {
if (!(n instanceof Element)) return;
// If the added node itself matches, set aria
if (SELECTORS.some((sel) => n.matches?.(sel))) {
try { n.setAttribute('aria-hidden', aria); } catch {}
}
// Descendants that match
try {
n.querySelectorAll?.(SELECTORS.join(',')).forEach((el) => el.setAttribute('aria-hidden', aria));
} catch {}
// Track open shadow roots on this node and its descendants
tryTrackShadowRoot(n);
n.querySelectorAll?.('*').forEach(tryTrackShadowRoot);
});
}
});
// ----- Optional: slow poll as a belt-and-suspenders for flaky vendors -----
let pollStop = null;
function startPollingForMatches(timeoutMs = 15000, intervalMs = 500) {
const start = Date.now();
const t = setInterval(() => {
const matches = queryAllDeep(SELECTORS);
if (matches.length > 0 || Date.now() - start > timeoutMs) {
// Sync aria to current hidden state when the first ones appear
const hidden = document.documentElement.classList.contains(ROOT_TOGGLE_CLASS);
const aria = hidden ? 'true' : 'false';
matches.forEach((el) => { try { el.setAttribute('aria-hidden', aria); } catch {} });
clearInterval(t);
pollStop = null;
}
}, intervalMs);
pollStop = () => clearInterval(t);
}
// ----- Init -----
function initScrollPopupsHardened() {
ensureHiddenStyle();
// Observe entire document and any open shadow roots we discover
observer.observe(document.documentElement, { childList: true, subtree: true });
// Track currently open shadow roots (in case something already attached one)
document.querySelectorAll('*').forEach(tryTrackShadowRoot);
// Kick off polling to catch ultra-late vendors (optional but pragmatic)
startPollingForMatches();
const featureEnabled = ENABLE_ON_ALL_DEVICES || isMobileLike();
if (!featureEnabled) {
applyVisibility('show');
return () => {
observer.disconnect();
if (pollStop) pollStop();
};
}
let prevY = window.scrollY;
let ticking = false;
const controller = new AbortController();
const { signal } = controller;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
try {
const currY = window.scrollY;
const action = decideVisibilityAction(prevY, currY, MIN_DELTA);
applyVisibility(action);
prevY = currY;
} finally {
ticking = false;
}
});
};
const onResize = () => {
if (!ENABLE_ON_ALL_DEVICES && !isMobileLike()) {
applyVisibility('show');
controller.abort();
observer.disconnect();
if (pollStop) pollStop();
}
};
window.addEventListener('scroll', onScroll, { passive: true, signal });
window.addEventListener('resize', onResize, { passive: true, signal });
window.addEventListener('orientationchange', onResize, { passive: true, signal });
return () => {
controller.abort();
observer.disconnect();
if (pollStop) pollStop();
};
}
// Auto-init
document.readyState !== 'loading'
? initScrollPopupsHardened()
: document.addEventListener('DOMContentLoaded', () => initScrollPopupsHardened());