Add i18n support to cookie banner JS client

Replace all hardcoded user-facing strings with server-driven
texts from config.texts. Add language detection chain
(data-lang > html lang > navigator.language), pass ?lang
query param on config fetch, and use {{variable}} interpolation
for dynamic content in templates.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 10:34:49 +04:00
parent 3a3335f28a
commit ef60f080f1
9 changed files with 203 additions and 60 deletions

View File

@@ -12,6 +12,8 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import type { BannerTexts } from "./i18n";
import { interpolate } from "./i18n";
import { removeCookies } from "./cookie-utils"; import { removeCookies } from "./cookie-utils";
import { LOCK_ICON } from "./html"; import { LOCK_ICON } from "./html";
@@ -111,6 +113,7 @@ function createPlaceholder(
el: Element, el: Element,
category: string, category: string,
label?: string, label?: string,
texts?: BannerTexts,
): void { ): void {
if (el.hasAttribute(ATTR_HIDDEN)) return; if (el.hasAttribute(ATTR_HIDDEN)) return;
@@ -152,10 +155,15 @@ function createPlaceholder(
} }
} }
const phText = texts?.placeholder_text
? interpolate(texts.placeholder_text, { category: escapeHtml(displayLabel) })
: `This content requires <strong>${escapeHtml(displayLabel)}</strong> cookies.`;
const phButton = texts?.placeholder_button ?? "Manage cookie preferences";
placeholder.innerHTML = [ placeholder.innerHTML = [
`<span class="probo-ph-icon">${LOCK_ICON}</span>`, `<span class="probo-ph-icon">${LOCK_ICON}</span>`,
`<p class="probo-ph-text">This content requires <strong>${escapeHtml(displayLabel)}</strong> cookies.</p>`, `<p class="probo-ph-text">${phText}</p>`,
`<button type="button" class="probo-ph-link">Manage cookie preferences</button>`, `<button type="button" class="probo-ph-link">${escapeHtml(phButton)}</button>`,
].join(""); ].join("");
placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => { placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => {
@@ -362,13 +370,14 @@ export function activateElements(
export function addPlaceholders( export function addPlaceholders(
consentData: Record<string, boolean>, consentData: Record<string, boolean>,
categoryLabels: Record<string, string>, categoryLabels: Record<string, string>,
texts?: BannerTexts,
): void { ): void {
const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`); const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`);
for (const el of elements) { for (const el of elements) {
const category = el.getAttribute(ATTR_CATEGORY); const category = el.getAttribute(ATTR_CATEGORY);
if (!category || consentData[category]) continue; if (!category || consentData[category]) continue;
if (!VISUAL_TAGS.has(el.tagName)) continue; if (!VISUAL_TAGS.has(el.tagName)) continue;
createPlaceholder(el, category, categoryLabels[category]); createPlaceholder(el, category, categoryLabels[category], texts);
} }
} }
@@ -376,16 +385,18 @@ function tryPlaceholder(
el: Element, el: Element,
consentData: Record<string, boolean>, consentData: Record<string, boolean>,
categoryLabels: Record<string, string>, categoryLabels: Record<string, string>,
texts?: BannerTexts,
): void { ): void {
const category = el.getAttribute(ATTR_CATEGORY); const category = el.getAttribute(ATTR_CATEGORY);
if (!category || consentData[category]) return; if (!category || consentData[category]) return;
if (!VISUAL_TAGS.has(el.tagName)) return; if (!VISUAL_TAGS.has(el.tagName)) return;
createPlaceholder(el, category, categoryLabels[category]); createPlaceholder(el, category, categoryLabels[category], texts);
} }
export function observeAndActivate( export function observeAndActivate(
consentData: Record<string, boolean>, consentData: Record<string, boolean>,
categoryLabels: Record<string, string>, categoryLabels: Record<string, string>,
texts?: BannerTexts,
): MutationObserver { ): MutationObserver {
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
@@ -396,13 +407,13 @@ export function observeAndActivate(
if (node.hasAttribute(ATTR_CATEGORY)) { if (node.hasAttribute(ATTR_CATEGORY)) {
tryActivate(node, consentData); tryActivate(node, consentData);
tryPlaceholder(node, consentData, categoryLabels); tryPlaceholder(node, consentData, categoryLabels, texts);
} }
const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`); const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`);
for (const el of nested) { for (const el of nested) {
tryActivate(el, consentData); tryActivate(el, consentData);
tryPlaceholder(el, consentData, categoryLabels); tryPlaceholder(el, consentData, categoryLabels, texts);
} }
} }
} }

View File

@@ -22,9 +22,14 @@ import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
import { CookieDetector } from "./detector"; import { CookieDetector } from "./detector";
import { NotFoundError } from "./errors"; import { NotFoundError } from "./errors";
import { fetchJSON } from "./http"; import { fetchJSON } from "./http";
import type { BannerTexts } from "./i18n";
import { detectLanguage } from "./i18n";
import { enqueue, flush } from "./queue"; import { enqueue, flush } from "./queue";
import { getOrCreateVisitorId } from "./visitor"; import { getOrCreateVisitorId } from "./visitor";
export type { BannerTexts } from "./i18n";
export { interpolate } from "./i18n";
export interface CookieItem { export interface CookieItem {
name: string; name: string;
duration: string; duration: string;
@@ -41,11 +46,15 @@ export interface Category {
export interface BannerConfig { export interface BannerConfig {
banner_id: string; banner_id: string;
version: number; version: number;
language: string;
default_language: string;
available_languages: string[];
privacy_policy_url: string; privacy_policy_url: string;
consent_expiry_days: number; consent_expiry_days: number;
consent_mode: "OPT_IN" | "OPT_OUT"; consent_mode: "OPT_IN" | "OPT_OUT";
show_branding: boolean; show_branding: boolean;
categories: Category[]; categories: Category[];
texts: BannerTexts;
} }
export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC"; export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC";
@@ -68,12 +77,14 @@ export interface ConsentRecord {
export interface CookieBannerClientOptions { export interface CookieBannerClientOptions {
bannerId: string; bannerId: string;
baseUrl: string; baseUrl: string;
lang?: string;
} }
export class CookieBannerClient { export class CookieBannerClient {
private readonly baseUrl: URL; private readonly baseUrl: URL;
private readonly bannerId: string; private readonly bannerId: string;
private readonly visitorId: string; private readonly visitorId: string;
private readonly lang: string;
private bannerConfig: BannerConfig | null = null; private bannerConfig: BannerConfig | null = null;
private consent: VisitorConsent | null = null; private consent: VisitorConsent | null = null;
@@ -88,10 +99,14 @@ export class CookieBannerClient {
this.baseUrl = new URL(base); this.baseUrl = new URL(base);
this.bannerId = config.bannerId; this.bannerId = config.bannerId;
this.visitorId = getOrCreateVisitorId(config.bannerId); this.visitorId = getOrCreateVisitorId(config.bannerId);
this.lang = detectLanguage(config.lang);
} }
async load(): Promise<void> { async load(): Promise<void> {
const configUrl = new URL(`${this.bannerId}/config`, this.baseUrl); const configUrl = new URL(`${this.bannerId}/config`, this.baseUrl);
if (this.lang) {
configUrl.searchParams.set("lang", this.lang);
}
const config = await fetchJSON<BannerConfig>(configUrl); const config = await fetchJSON<BannerConfig>(configUrl);
this.bannerConfig = config; this.bannerConfig = config;
@@ -237,13 +252,14 @@ export class CookieBannerClient {
categoryLabels[cat.name] = cat.name; categoryLabels[cat.name] = cat.name;
} }
const texts = this.config.texts;
deactivateElements(consentData, categoryCookies, categoryLabels); deactivateElements(consentData, categoryCookies, categoryLabels);
activateElements(consentData); activateElements(consentData);
addPlaceholders(consentData, categoryLabels); addPlaceholders(consentData, categoryLabels, texts);
if (this.observer) { if (this.observer) {
this.observer.disconnect(); this.observer.disconnect();
} }
this.observer = observeAndActivate(consentData, categoryLabels); this.observer = observeAndActivate(consentData, categoryLabels, texts);
} }
private startDetector(config: BannerConfig): void { private startDetector(config: BannerConfig): void {

View File

@@ -24,7 +24,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
private _draft: ConsentDraft = {}; private _draft: ConsentDraft = {};
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
return ["banner-id", "base-url", "reopen-widget"]; return ["banner-id", "base-url", "reopen-widget", "lang"];
} }
get client(): CookieBannerClient { get client(): CookieBannerClient {
@@ -129,7 +129,9 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
return; return;
} }
this._client = new CookieBannerClient({ bannerId, baseUrl }); const lang = this.getAttribute("lang") ?? undefined;
this._client = new CookieBannerClient({ bannerId, baseUrl, lang });
try { try {
await this._client.load(); await this._client.load();

View File

@@ -13,8 +13,10 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import type { CookieItem } from "../client"; import type { CookieItem } from "../client";
import { type BannerTexts, interpolate } from "../i18n";
import { ProboElement } from "./base"; import { ProboElement } from "./base";
import type { ProboCategory } from "./category"; import type { ProboCategory } from "./category";
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
export class ProboCookieList extends ProboElement { export class ProboCookieList extends ProboElement {
private category: ProboCategory | null = null; private category: ProboCategory | null = null;
@@ -35,13 +37,16 @@ export class ProboCookieList extends ProboElement {
private stamp(): void { private stamp(): void {
if (!this.template || !this.category) return; if (!this.template || !this.category) return;
const root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
const texts = root?.bannerConfig?.texts;
const cookies = this.category.cookies; const cookies = this.category.cookies;
for (const cookie of cookies) { for (const cookie of cookies) {
this.stampCookie(cookie); this.stampCookie(cookie, texts);
} }
} }
private stampCookie(cookie: CookieItem): void { private stampCookie(cookie: CookieItem, texts?: BannerTexts): void {
if (!this.template) return; if (!this.template) return;
const wrapper = document.createElement("probo-cookie"); const wrapper = document.createElement("probo-cookie");
@@ -52,6 +57,10 @@ export class ProboCookieList extends ProboElement {
duration: cookie.duration, duration: cookie.duration,
description: cookie.description, description: cookie.description,
}); });
this.fillLabels(clone, texts, {
description: cookie.description,
duration: cookie.duration,
});
wrapper.appendChild(clone); wrapper.appendChild(clone);
this.appendChild(wrapper); this.appendChild(wrapper);
@@ -68,6 +77,25 @@ export class ProboCookieList extends ProboElement {
} }
} }
} }
private fillLabels(
fragment: DocumentFragment,
texts: BannerTexts | undefined,
values: Record<string, string>,
): void {
const labels = fragment.querySelectorAll("[data-label]");
for (const el of labels) {
const key = el.getAttribute("data-label")!;
const slotName = key.replace("label_", "");
const value = values[slotName] ?? "";
const tpl = texts?.[key];
if (tpl) {
el.textContent = interpolate(tpl, { value });
const slot = el.querySelector(`[data-slot="${slotName}"]`);
if (slot) slot.remove();
}
}
}
} }
export class ProboCookie extends ProboElement {} export class ProboCookie extends ProboElement {}

View File

@@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement {
} }
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
return ["position"]; return ["position", "aria-settings-label"];
} }
private get position(): string { private get position(): string {
@@ -89,6 +89,11 @@ export class ProboSettingsButton extends HTMLElement {
const btn = this.shadow.querySelector("button"); const btn = this.shadow.querySelector("button");
btn?.addEventListener("click", this.handleClick); btn?.addEventListener("click", this.handleClick);
const ariaLabel = this.getAttribute("aria-settings-label");
if (ariaLabel && btn) {
btn.setAttribute("aria-label", ariaLabel);
}
} }
disconnectedCallback(): void { disconnectedCallback(): void {

View File

@@ -12,25 +12,28 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
// [unitSeconds, singular, plural, snapBuffer] import type { BannerTexts } from "./i18n";
import { interpolate } from "./i18n";
// [unitSeconds, textKey, snapBuffer]
// snapBuffer: if the remainder is within this many seconds of the next // snapBuffer: if the remainder is within this many seconds of the next
// whole unit, round up instead of carrying into smaller units. // whole unit, round up instead of carrying into smaller units.
const DURATION_UNITS: [number, string, string, number][] = [ const DURATION_UNITS: [number, string, number][] = [
[365 * 24 * 3600, "year", "years", 21 * 24 * 3600], [365 * 24 * 3600, "duration_year", 21 * 24 * 3600],
[30 * 24 * 3600, "month", "months", 2 * 24 * 3600], [30 * 24 * 3600, "duration_month", 2 * 24 * 3600],
[7 * 24 * 3600, "week", "weeks", 12 * 3600], [7 * 24 * 3600, "duration_week", 12 * 3600],
[24 * 3600, "day", "days", 0], [24 * 3600, "duration_day", 0],
[3600, "hour", "hours", 5 * 60], [3600, "duration_hour", 5 * 60],
[60, "minute", "minutes", 15], [60, "duration_minute", 15],
]; ];
function humanizeDuration(seconds: number): string { function humanizeDuration(seconds: number, texts?: BannerTexts): string {
if (seconds <= 0) return "session"; if (seconds <= 0) return texts?.duration_session ?? "session";
let remaining = seconds; let remaining = seconds;
const parts: string[] = []; const parts: string[] = [];
for (const [unit, singular, plural, snap] of DURATION_UNITS) { for (const [unit, key, snap] of DURATION_UNITS) {
if (remaining >= unit) { if (remaining >= unit) {
let count = Math.floor(remaining / unit); let count = Math.floor(remaining / unit);
const leftover = remaining - count * unit; const leftover = remaining - count * unit;
@@ -44,11 +47,13 @@ function humanizeDuration(seconds: number): string {
remaining = leftover; remaining = leftover;
} }
parts.push(count === 1 ? `1 ${singular}` : `${count} ${plural}`); const tplKey = count === 1 ? `${key}_one` : `${key}_other`;
const tpl = texts?.[tplKey] ?? (count === 1 ? `{{count}} ${key.replace("duration_", "")}` : `{{count}} ${key.replace("duration_", "")}s`);
parts.push(interpolate(tpl, { count: String(count) }));
} }
} }
return parts.length > 0 ? parts.join(", ") : "session"; return parts.length > 0 ? parts.join(", ") : texts?.duration_session ?? "session";
} }
export function parseCookieName(raw: string): string { export function parseCookieName(raw: string): string {
@@ -57,15 +62,16 @@ export function parseCookieName(raw: string): string {
return raw.substring(0, eqIdx).trim(); return raw.substring(0, eqIdx).trim();
} }
export function parseDuration(raw: string): string { export function parseDuration(raw: string, texts?: BannerTexts): string {
const session = texts?.duration_session ?? "session";
const parts = raw.split(";").map((s) => s.trim()); const parts = raw.split(";").map((s) => s.trim());
for (const part of parts) { for (const part of parts) {
const lower = part.toLowerCase(); const lower = part.toLowerCase();
if (lower.startsWith("max-age=")) { if (lower.startsWith("max-age=")) {
const val = parseInt(part.substring(8), 10); const val = parseInt(part.substring(8), 10);
if (isNaN(val) || val <= 0) return "session"; if (isNaN(val) || val <= 0) return session;
return humanizeDuration(val); return humanizeDuration(val, texts);
} }
} }
@@ -74,16 +80,16 @@ export function parseDuration(raw: string): string {
if (lower.startsWith("expires=")) { if (lower.startsWith("expires=")) {
const dateStr = part.substring(8); const dateStr = part.substring(8);
const expires = new Date(dateStr); const expires = new Date(dateStr);
if (isNaN(expires.getTime())) return "session"; if (isNaN(expires.getTime())) return session;
const deltaSeconds = Math.round( const deltaSeconds = Math.round(
(expires.getTime() - Date.now()) / 1000, (expires.getTime() - Date.now()) / 1000,
); );
if (deltaSeconds <= 0) return "session"; if (deltaSeconds <= 0) return session;
return humanizeDuration(deltaSeconds); return humanizeDuration(deltaSeconds, texts);
} }
} }
return "session"; return session;
} }
export function isDeletion(raw: string): boolean { export function isDeletion(raw: string): boolean {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export interface BannerTexts {
[key: string]: string;
}
export function detectLanguage(explicit?: string): string {
if (explicit) return explicit;
const htmlLang = document.documentElement.lang;
if (htmlLang) return htmlLang.split("-")[0];
if (typeof navigator !== "undefined" && navigator.language) {
return navigator.language.split("-")[0];
}
return "";
}
export function interpolate(
template: string,
vars: Record<string, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
}

View File

@@ -38,6 +38,11 @@ if (script) {
el.setAttribute("reopen-widget", reopenWidget); el.setAttribute("reopen-widget", reopenWidget);
} }
const lang = script.getAttribute("data-lang");
if (lang) {
el.setAttribute("lang", lang);
}
document.body.appendChild(el); document.body.appendChild(el);
}; };

View File

@@ -15,6 +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 { 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";
@@ -27,7 +28,7 @@ export class ProboThemedBanner extends HTMLElement {
} }
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
return ["banner-id", "base-url", "reopen-widget"]; return ["banner-id", "base-url", "reopen-widget", "lang"];
} }
connectedCallback(): void { connectedCallback(): void {
@@ -44,21 +45,21 @@ export class ProboThemedBanner extends HTMLElement {
const position = this.getAttribute("position") ?? "bottom-left"; const position = this.getAttribute("position") ?? "bottom-left";
const reopenWidget = this.getAttribute("reopen-widget"); const reopenWidget = this.getAttribute("reopen-widget");
const reopenAttr = reopenWidget ? ` reopen-widget="${this.esc(reopenWidget)}"` : ""; const reopenAttr = reopenWidget ? ` reopen-widget="${this.esc(reopenWidget)}"` : "";
const lang = this.getAttribute("lang");
const langAttr = lang ? ` lang="${this.esc(lang)}"` : "";
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style>${THEMED_STYLES}</style> <style>${THEMED_STYLES}</style>
<probo-cookie-banner-root banner-id="${this.esc(bannerId)}" base-url="${this.esc(baseUrl)}"${reopenAttr}> <probo-cookie-banner-root banner-id="${this.esc(bannerId)}" base-url="${this.esc(baseUrl)}"${reopenAttr}${langAttr}>
<probo-banner> <probo-banner>
<div class="floating" data-position="${this.esc(position)}"> <div class="floating" data-position="${this.esc(position)}">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-banner-title" aria-describedby="probo-banner-desc"> <div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-banner-title" aria-describedby="probo-banner-desc">
<p class="title" id="probo-banner-title">Cookie Preferences</p> <p class="title" id="probo-banner-title" data-text="banner_title"></p>
<p class="description" id="probo-banner-desc" data-description> <p class="description" id="probo-banner-desc" data-text="banner_description"></p>
We use cookies to improve your experience and analyze site traffic.
</p>
<div class="buttons"> <div class="buttons">
<probo-accept-button><button class="btn btn-primary">Accept all</button></probo-accept-button> <probo-accept-button><button class="btn btn-primary" data-text="button_accept_all"></button></probo-accept-button>
<probo-reject-button><button class="btn">Reject all</button></probo-reject-button> <probo-reject-button><button class="btn" data-text="button_reject_all"></button></probo-reject-button>
<probo-customize-button><button class="btn btn-link">Customize</button></probo-customize-button> <probo-customize-button><button class="btn btn-link" data-text="button_customize"></button></probo-customize-button>
</div> </div>
${BRANDING} ${BRANDING}
</div> </div>
@@ -70,16 +71,16 @@ export class ProboThemedBanner extends HTMLElement {
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-panel-title" aria-describedby="probo-panel-desc"> <div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-panel-title" aria-describedby="probo-panel-desc">
<div class="panel-header"> <div class="panel-header">
<div class="panel-header-title"> <div class="panel-header-title">
<p class="title" id="probo-panel-title" style="margin:0">Customise Preferences</p> <p class="title" id="probo-panel-title" style="margin:0" data-text="panel_title"></p>
<button class="panel-close" data-action="back" aria-label="Close"> <button class="panel-close" data-action="back" data-aria-text="aria_close">
${CLOSE_ICON} ${CLOSE_ICON}
</button> </button>
</div> </div>
<p class="description" id="probo-panel-desc">Choose which cookie categories to allow. Necessary cookies are always active as they are needed for the site to work.</p> <p class="description" id="probo-panel-desc" data-text="panel_description"></p>
</div> </div>
<probo-category-list> <probo-category-list>
<template> <template>
<button class="cookie-toggle" data-action="toggle-cookies" aria-expanded="false" aria-label="Show cookie details"> <button class="cookie-toggle" data-action="toggle-cookies" aria-expanded="false" data-aria-text="aria_show_details">
${CHEVRON_DOWN} ${CHEVRON_DOWN}
</button> </button>
<div class="category-header"> <div class="category-header">
@@ -98,8 +99,8 @@ export class ProboThemedBanner extends HTMLElement {
<template> <template>
<div class="cookie-item"> <div class="cookie-item">
<span class="cookie-name" data-slot="name"></span> <span class="cookie-name" data-slot="name"></span>
<span class="cookie-detail"><span class="cookie-label">Description:</span> <span data-slot="description"></span></span> <span class="cookie-detail" data-label="label_description"><span data-slot="description"></span></span>
<span class="cookie-detail"><span class="cookie-label">Duration:</span> <span data-slot="duration"></span></span> <span class="cookie-detail" data-label="label_duration"><span data-slot="duration"></span></span>
</div> </div>
</template> </template>
</probo-cookie-list> </probo-cookie-list>
@@ -107,10 +108,10 @@ export class ProboThemedBanner extends HTMLElement {
</probo-category-list> </probo-category-list>
<div class="footer"> <div class="footer">
<div class="buttons"> <div class="buttons">
<probo-accept-button><button class="btn btn-primary">Accept all</button></probo-accept-button> <probo-accept-button><button class="btn btn-primary" data-text="button_accept_all"></button></probo-accept-button>
<probo-reject-button><button class="btn">Reject all</button></probo-reject-button> <probo-reject-button><button class="btn" data-text="button_reject_all"></button></probo-reject-button>
<probo-save-button> <probo-save-button>
<button class="btn btn-link" style="flex:1">Save preferences</button> <button class="btn btn-link" style="flex:1" data-text="button_save"></button>
</probo-save-button> </probo-save-button>
</div> </div>
${BRANDING} ${BRANDING}
@@ -127,7 +128,7 @@ export class ProboThemedBanner extends HTMLElement {
root.addEventListener("probo-ready", (e: Event) => { root.addEventListener("probo-ready", (e: Event) => {
const config = (e as CustomEvent).detail.config as BannerConfig; const config = (e as CustomEvent).detail.config as BannerConfig;
this.updateDescription(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", "");
@@ -154,19 +155,51 @@ export class ProboThemedBanner extends HTMLElement {
btn.classList.remove("open"); btn.classList.remove("open");
} }
btn.setAttribute("aria-expanded", String(open)); btn.setAttribute("aria-expanded", String(open));
btn.setAttribute("aria-label", open ? "Hide cookie details" : "Show cookie details"); const texts = root.bannerConfig?.texts;
const showLabel = texts?.aria_show_details ?? "Show cookie details";
const hideLabel = texts?.aria_hide_details ?? "Hide cookie details";
btn.setAttribute("aria-label", open ? hideLabel : showLabel);
}); });
} }
private updateDescription(config: BannerConfig): void { private applyTexts(config: BannerConfig): void {
const el = this.shadow.querySelector("[data-description]"); const texts = config.texts ?? {};
if (!el) return;
let html = "We use cookies to improve your experience and analyze site traffic."; const necessaryCategory = config.categories.find(c => c.kind === "NECESSARY");
const necessaryCategoryName = necessaryCategory?.name ?? "Necessary";
this.shadow.querySelectorAll("[data-text]").forEach(el => {
const key = el.getAttribute("data-text")!;
const raw = texts[key];
if (!raw) return;
if (key === "banner_description") {
let link = "";
if (config.privacy_policy_url) { if (config.privacy_policy_url) {
html += ` <a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`; const linkText = this.esc(texts.privacy_policy_link_text ?? "Privacy Policy");
link = `<a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
}
el.innerHTML = interpolate(raw, { privacy_policy_link: link });
} else if (key === "panel_description") {
el.textContent = interpolate(raw, { necessary_category: necessaryCategoryName });
} else {
el.textContent = raw;
}
});
this.shadow.querySelectorAll("[data-aria-text]").forEach(el => {
const key = el.getAttribute("data-aria-text")!;
const raw = texts[key];
if (raw) el.setAttribute("aria-label", raw);
});
const settingsBtn = this.shadow.querySelector("probo-settings-button");
if (settingsBtn) {
const ariaText = texts.aria_cookie_settings;
if (ariaText) {
settingsBtn.setAttribute("aria-settings-label", ariaText);
}
} }
el.innerHTML = html;
} }
private esc(str: string): string { private esc(str: string): string {