Add Global Privacy Control (GPC) support to cookie banner SDK

When navigator.globalPrivacyControl is true and no prior consent exists,
auto-reject all non-necessary cookies with action "GPC", skip showing
the banner, and display an "Opt-Out Preference Signal Honored" badge on
the settings button (CPRA compliance). GPC labels are hardcoded in the
SDK for en/fr/de/es. Users can still override via the preference panel.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-24 11:13:43 +04:00
parent 2b6f131f43
commit 98dc04b7de
6 changed files with 70 additions and 4 deletions

View File

@@ -87,6 +87,7 @@ export class CookieBannerClient {
private consent: VisitorConsent | null = null; private consent: VisitorConsent | null = null;
private observer: MutationObserver | null = null; private observer: MutationObserver | null = null;
private detector: CookieDetector | null = null; private detector: CookieDetector | null = null;
private _gpcApplied = false;
constructor(config: CookieBannerClientOptions) { constructor(config: CookieBannerClientOptions) {
let base = config.baseUrl; let base = config.baseUrl;
@@ -152,6 +153,11 @@ export class CookieBannerClient {
this.consent = null; this.consent = null;
} }
if (!this.consent && this.gpcDetected) {
this.gpc();
this._gpcApplied = true;
}
void flush(this.bannerId); void flush(this.bannerId);
} }
@@ -170,6 +176,26 @@ export class CookieBannerClient {
return this.consent !== null; return this.consent !== null;
} }
get gpcDetected(): boolean {
return typeof navigator !== "undefined" &&
(navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true;
}
get gpcApplied(): boolean {
return this._gpcApplied;
}
gpc(): void {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) {
consentData[cat.slug] = cat.kind === "NECESSARY";
}
this.recordConsent("GPC", consentData);
}
acceptAll(): void { acceptAll(): void {
const cfg = this.config; const cfg = this.config;

View File

@@ -66,6 +66,7 @@ export interface ProboRootElement extends ProboElement {
readonly state: ProboState; readonly state: ProboState;
readonly reopenWidget: string; readonly reopenWidget: string;
readonly consentDraft: ConsentDraft; readonly consentDraft: ConsentDraft;
readonly gpcApplied: boolean;
setState(state: ProboState): void; setState(state: ProboState): void;
updateDraft(category: string, value: boolean): void; updateDraft(category: string, value: boolean): void;
} }

View File

@@ -53,6 +53,10 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
return this._draft; return this._draft;
} }
get gpcApplied(): boolean {
return this._client?.gpcApplied ?? false;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (name === "reopen-widget" && oldValue !== newValue) { if (name === "reopen-widget" && oldValue !== newValue) {
this.dispatchEvent( this.dispatchEvent(
@@ -150,7 +154,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
new CustomEvent("probo-ready", { new CustomEvent("probo-ready", {
bubbles: true, bubbles: true,
composed: true, composed: true,
detail: { config: this._config }, detail: { config: this._config, gpcApplied: this.gpcApplied },
}), }),
); );

View File

@@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement {
} }
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
return ["position", "aria-settings-label"]; return ["position", "aria-settings-label", "gpc-label"];
} }
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {
@@ -36,6 +36,9 @@ export class ProboSettingsButton extends HTMLElement {
btn.setAttribute("aria-label", newValue); btn.setAttribute("aria-label", newValue);
} }
} }
if (name === "gpc-label") {
this.updateGpcBadge(newValue);
}
} }
private get position(): string { private get position(): string {
@@ -73,10 +76,19 @@ export class ProboSettingsButton extends HTMLElement {
} }
button:hover { opacity: 0.85; } button:hover { opacity: 0.85; }
.icon { display: flex; flex-shrink: 0; } .icon { display: flex; flex-shrink: 0; }
.gpc-badge {
display: none;
font-size: 11px;
line-height: 1;
font-weight: 600;
white-space: nowrap;
}
:host([gpc-label]) .gpc-badge { display: inline; }
::slotted(*) { display: contents; } ::slotted(*) { display: contents; }
</style> </style>
<button part="button" aria-label="Cookie settings"> <button part="button" aria-label="Cookie settings">
<span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span> <span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span>
<span class="gpc-badge" part="gpc-badge"></span>
<slot></slot> <slot></slot>
</button> </button>
`; `;
@@ -141,4 +153,11 @@ export class ProboSettingsButton extends HTMLElement {
if (!this.root) return; if (!this.root) return;
this.root.setState("panel"); this.root.setState("panel");
}; };
private updateGpcBadge(label: string | null): void {
const badge = this.shadow.querySelector(".gpc-badge");
if (badge) {
badge.textContent = label ?? "";
}
}
} }

View File

@@ -52,3 +52,14 @@ const COOKIE_DETAIL_LABELS: Record<string, Record<string, string>> = {
export function getCookieDetailLabels(lang: string): Record<string, string> { export function getCookieDetailLabels(lang: string): Record<string, string> {
return COOKIE_DETAIL_LABELS[lang] ?? COOKIE_DETAIL_LABELS.en; return COOKIE_DETAIL_LABELS[lang] ?? COOKIE_DETAIL_LABELS.en;
} }
const GPC_LABELS: Record<string, string> = {
en: "Opt-Out Preference Signal Honored",
fr: "Signal de préférence de désinscription respecté",
de: "Opt-Out-Präferenzsignal beachtet",
es: "Señal de exclusión respetada",
};
export function getGpcLabel(lang: string): string {
return GPC_LABELS[lang] ?? GPC_LABELS.en;
}

View File

@@ -15,7 +15,7 @@
import { registerComponents } from "../components"; import { registerComponents } from "../components";
import type { ProboCookieBannerRoot } from "../components/cookie-banner-root"; import type { ProboCookieBannerRoot } from "../components/cookie-banner-root";
import type { BannerConfig } from "../client"; import type { BannerConfig } from "../client";
import { interpolate } from "../i18n"; import { getGpcLabel, interpolate } from "../i18n";
import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html"; import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html";
import { THEMED_STYLES } from "./styles"; import { THEMED_STYLES } from "./styles";
@@ -127,13 +127,18 @@ export class ProboThemedBanner extends HTMLElement {
const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot; const root = this.shadow.querySelector("probo-cookie-banner-root") as ProboCookieBannerRoot;
root.addEventListener("probo-ready", (e: Event) => { root.addEventListener("probo-ready", (e: Event) => {
const config = (e as CustomEvent).detail.config as BannerConfig; const detail = (e as CustomEvent).detail;
const config = detail.config as BannerConfig;
this.applyTexts(config); this.applyTexts(config);
if (!config.show_branding) { if (!config.show_branding) {
this.shadow.querySelectorAll("[data-branding]").forEach(el => { this.shadow.querySelectorAll("[data-branding]").forEach(el => {
(el as HTMLElement).setAttribute("hidden", ""); (el as HTMLElement).setAttribute("hidden", "");
}); });
} }
if (detail.gpcApplied) {
const settingsBtn = this.shadow.querySelector("probo-settings-button");
settingsBtn?.setAttribute("gpc-label", getGpcLabel(config.language));
}
}); });
this.shadow.querySelector("[data-action=back]")?.addEventListener("click", () => { this.shadow.querySelector("[data-action=back]")?.addEventListener("click", () => {