From 9a92ebd51a2f853bde8b990c3b279befb36616f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 21 Apr 2026 20:22:58 +0400 Subject: [PATCH] Add placeholder overlays for blocked cookie-consent elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When visual elements (iframe, img, video, etc.) are blocked due to missing cookie consent, display a styled placeholder that explains which cookie category must be accepted and offers a link to open the preference panel. Signed-off-by: Émile Ré --- packages/cookie-banner/src/activation.ts | 166 +++++++++++++++++- packages/cookie-banner/src/client.ts | 8 +- .../src/components/cookie-banner-root.ts | 6 + 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/packages/cookie-banner/src/activation.ts b/packages/cookie-banner/src/activation.ts index 292dea02e..f9a2a20e0 100644 --- a/packages/cookie-banner/src/activation.ts +++ b/packages/cookie-banner/src/activation.ts @@ -16,6 +16,8 @@ const ATTR_CATEGORY = "data-cookie-consent"; const ATTR_SRC = "data-src"; const ATTR_HREF = "data-href"; const ATTR_ACTIVATED = "data-cookie-consent-activated"; +const ATTR_PLACEHOLDER = "data-cookie-consent-placeholder"; +const ATTR_HIDDEN = "data-cookie-consent-hidden"; const ACTIVATABLE_TAGS = new Set([ "SCRIPT", @@ -28,6 +30,132 @@ const ACTIVATABLE_TAGS = new Set([ "LINK", ]); +const VISUAL_TAGS = new Set([ + "IFRAME", + "IMG", + "VIDEO", + "AUDIO", + "EMBED", + "OBJECT", +]); + +const LOCK_ICON = ``; + +const PLACEHOLDER_STYLES = ` +[${ATTR_HIDDEN}] { + display: none !important; +} +[${ATTR_PLACEHOLDER}] { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + padding: 24px; + background: var(--probo-bg, #ffffff); + color: var(--probo-text-secondary, #555555); + border: 1px dashed var(--probo-border, #e0e0e0); + border-radius: var(--probo-radius, 12px); + font-family: var(--probo-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif); + font-size: var(--probo-font-size, 14px); + line-height: 1.5; + text-align: center; + box-sizing: border-box; + min-height: 120px; +} +[${ATTR_PLACEHOLDER}] .probo-ph-icon { + color: var(--probo-text-secondary, #555555); +} +[${ATTR_PLACEHOLDER}] .probo-ph-text { + margin: 0; +} +[${ATTR_PLACEHOLDER}] .probo-ph-text strong { + font-weight: 600; +} +[${ATTR_PLACEHOLDER}] .probo-ph-link { + background: none; + border: none; + color: var(--probo-accent, #1a1a1a); + text-decoration: underline; + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; +} +[${ATTR_PLACEHOLDER}] .probo-ph-link:hover { + opacity: 0.8; +} +`; + +let stylesInjected = false; + +function injectPlaceholderStyles(): void { + if (stylesInjected) return; + stylesInjected = true; + + const style = document.createElement("style"); + style.id = "probo-placeholder-styles"; + style.textContent = PLACEHOLDER_STYLES; + document.head.appendChild(style); +} + +function escapeHtml(s: string): string { + const el = document.createElement("span"); + el.textContent = s; + return el.innerHTML; +} + +function createPlaceholder( + el: Element, + category: string, + label?: string, +): void { + if (el.hasAttribute(ATTR_HIDDEN)) return; + + injectPlaceholderStyles(); + + const displayLabel = label || category; + + const placeholder = document.createElement("div"); + placeholder.setAttribute(ATTR_PLACEHOLDER, category); + + const htmlEl = el as HTMLElement; + + if (htmlEl.className) placeholder.className = htmlEl.className; + + const w = el.getAttribute("width"); + if (w) placeholder.style.width = /^\d+$/.test(w) ? w + "px" : w; + + const h = el.getAttribute("height"); + if (h) placeholder.style.height = /^\d+$/.test(h) ? h + "px" : h; + + if (htmlEl.style?.cssText) placeholder.style.cssText += htmlEl.style.cssText; + + placeholder.innerHTML = [ + `${LOCK_ICON}`, + `

This content requires ${escapeHtml(displayLabel)} cookies.

`, + ``, + ].join(""); + + placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("probo-open-preferences")); + }); + + el.setAttribute(ATTR_HIDDEN, ""); + el.parentNode!.insertBefore(placeholder, el.nextSibling); +} + +function removePlaceholder(el: Element): void { + if (!el.hasAttribute(ATTR_HIDDEN)) return; + + const next = el.nextElementSibling; + if (next?.hasAttribute(ATTR_PLACEHOLDER)) { + next.remove(); + } + + el.removeAttribute(ATTR_HIDDEN); +} + function activateScript(el: HTMLScriptElement): void { const replacement = document.createElement("script"); const originalType = el.getAttribute("data-type"); @@ -61,6 +189,8 @@ function activateScript(el: HTMLScriptElement): void { } function activateElement(el: Element): void { + removePlaceholder(el); + const category = el.getAttribute(ATTR_CATEGORY); const src = el.getAttribute(ATTR_SRC); @@ -140,7 +270,7 @@ function deactivateScript(el: HTMLScriptElement): void { el.parentNode!.replaceChild(replacement, el); } -function deactivateElement(el: Element): void { +function deactivateElement(el: Element, label?: string): void { const category = el.getAttribute(ATTR_ACTIVATED); const src = el.getAttribute("src"); @@ -159,6 +289,10 @@ function deactivateElement(el: Element): void { el.setAttribute(ATTR_CATEGORY, category); } el.removeAttribute(ATTR_ACTIVATED); + + if (category && VISUAL_TAGS.has(el.tagName)) { + createPlaceholder(el, category, label); + } } function removeCookies(names: string[]): void { @@ -175,6 +309,7 @@ function removeCookies(names: string[]): void { export function deactivateElements( consentData: Record, categoryCookies: Record, + categoryLabels: Record, ): void { const elements = document.querySelectorAll(`[${ATTR_ACTIVATED}]`); const cookiesToRemove = new Set(); @@ -188,7 +323,7 @@ export function deactivateElements( if (el instanceof HTMLScriptElement) { deactivateScript(el); } else { - deactivateElement(el); + deactivateElement(el, categoryLabels[category]); } const cookies = categoryCookies[category]; @@ -213,8 +348,33 @@ export function activateElements( } } +export function addPlaceholders( + consentData: Record, + categoryLabels: Record, +): void { + const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`); + for (const el of elements) { + const category = el.getAttribute(ATTR_CATEGORY); + if (!category || consentData[category]) continue; + if (!VISUAL_TAGS.has(el.tagName)) continue; + createPlaceholder(el, category, categoryLabels[category]); + } +} + +function tryPlaceholder( + el: Element, + consentData: Record, + categoryLabels: Record, +): void { + const category = el.getAttribute(ATTR_CATEGORY); + if (!category || consentData[category]) return; + if (!VISUAL_TAGS.has(el.tagName)) return; + createPlaceholder(el, category, categoryLabels[category]); +} + export function observeAndActivate( consentData: Record, + categoryLabels: Record, ): MutationObserver { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { @@ -225,11 +385,13 @@ export function observeAndActivate( if (node.hasAttribute(ATTR_CATEGORY)) { tryActivate(node, consentData); + tryPlaceholder(node, consentData, categoryLabels); } const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`); for (const el of nested) { tryActivate(el, consentData); + tryPlaceholder(el, consentData, categoryLabels); } } } diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index fc954a6c5..8c42195de 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -14,6 +14,7 @@ import { activateElements, + addPlaceholders, deactivateElements, observeAndActivate, } from "./activation"; @@ -241,16 +242,19 @@ export class CookieBannerClient { private activate(consentData: Record): void { const categoryCookies: Record = {}; + const categoryLabels: Record = {}; for (const cat of this.config.categories) { categoryCookies[cat.name] = cat.cookies.map((c) => c.name); + categoryLabels[cat.name] = cat.name; } - deactivateElements(consentData, categoryCookies); + deactivateElements(consentData, categoryCookies, categoryLabels); activateElements(consentData); + addPlaceholders(consentData, categoryLabels); if (this.observer) { this.observer.disconnect(); } - this.observer = observeAndActivate(consentData); + this.observer = observeAndActivate(consentData, categoryLabels); } destroy(): void { diff --git a/packages/cookie-banner/src/components/cookie-banner-root.ts b/packages/cookie-banner/src/components/cookie-banner-root.ts index 2df2fffdf..08462bd48 100644 --- a/packages/cookie-banner/src/components/cookie-banner-root.ts +++ b/packages/cookie-banner/src/components/cookie-banner-root.ts @@ -66,16 +66,22 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem } connectedCallback(): void { + document.addEventListener("probo-open-preferences", this.onOpenPreferences); this.initClient(); } disconnectedCallback(): void { + document.removeEventListener("probo-open-preferences", this.onOpenPreferences); if (this._client) { this._client.destroy(); this._client = null; } } + private onOpenPreferences = (): void => { + this.setState("panel"); + }; + setState(state: ProboState): void { const prev = this._state; this._state = state;