Add headless web components for cookie banner UI
Introduce Shadow DOM-based custom elements that let customers build their own cookie banner and preference panel while the SDK validates structural compliance and auto-renders categories/cookies from config. Components: probo-cookie-banner (root), probo-banner, probo-accept-button, probo-reject-button, probo-customize-button, probo-preference-panel, probo-category-list, probo-category, probo-category-toggle, probo-cookie-list, probo-cookie, probo-save-button, probo-settings-button. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
72
packages/cookie-banner/src/components/banner.ts
Normal file
72
packages/cookie-banner/src/components/banner.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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.
|
||||
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
const REQUIRED_BUTTONS = [
|
||||
"probo-accept-button",
|
||||
"probo-reject-button",
|
||||
"probo-customize-button",
|
||||
] as const;
|
||||
|
||||
export class ProboBanner extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
private onStateChange = (e: Event): void => {
|
||||
const { state } = (e as CustomEvent).detail;
|
||||
this.hidden = state !== "banner";
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
:host([hidden]) { display: none; }
|
||||
</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
|
||||
this.hidden = true;
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
|
||||
if (this.root) {
|
||||
this.root.addEventListener("probo-state", this.onStateChange);
|
||||
if (this.root.state === "banner") {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleValidation(() => this.validate());
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.root) {
|
||||
this.root.removeEventListener("probo-state", this.onStateChange);
|
||||
}
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
const missing: string[] = [];
|
||||
for (const tag of REQUIRED_BUTTONS) {
|
||||
if (!this.querySelector(tag)) {
|
||||
missing.push(tag);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
this.warn(`<probo-banner> is missing required children: ${missing.join(", ")}`);
|
||||
this.emitValidation(missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/cookie-banner/src/components/base.ts
Normal file
73
packages/cookie-banner/src/components/base.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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.
|
||||
|
||||
import type { CookieBannerClient, BannerConfig } from "../client";
|
||||
|
||||
export type ProboState = "loading" | "banner" | "panel" | "hidden";
|
||||
|
||||
export interface ConsentDraft {
|
||||
[category: string]: boolean;
|
||||
}
|
||||
|
||||
export class ProboElement extends HTMLElement {
|
||||
protected shadow: ShadowRoot;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
protected findAncestor<T extends HTMLElement>(tagName: string): T | null {
|
||||
let node: Node | null = this as Node;
|
||||
while (node) {
|
||||
const root = node.getRootNode();
|
||||
if (root instanceof ShadowRoot) {
|
||||
node = root.host;
|
||||
} else {
|
||||
node = (node as HTMLElement).parentElement;
|
||||
}
|
||||
if (node instanceof HTMLElement && node.tagName.toLowerCase() === tagName) {
|
||||
return node as T;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected scheduleValidation(fn: () => void): void {
|
||||
queueMicrotask(fn);
|
||||
}
|
||||
|
||||
protected warn(message: string): void {
|
||||
console.warn(`[probo] ${message}`);
|
||||
}
|
||||
|
||||
protected emitValidation(missing: string[]): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("probo-validation", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { missing },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProboRootElement extends ProboElement {
|
||||
readonly client: CookieBannerClient;
|
||||
readonly bannerConfig: BannerConfig;
|
||||
readonly state: ProboState;
|
||||
readonly consentDraft: ConsentDraft;
|
||||
setState(state: ProboState): void;
|
||||
updateDraft(category: string, value: boolean): void;
|
||||
}
|
||||
75
packages/cookie-banner/src/components/buttons.ts
Normal file
75
packages/cookie-banner/src/components/buttons.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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.
|
||||
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
class ProboActionButton extends ProboElement {
|
||||
protected root: ProboRootElement | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
this.addEventListener("click", this.handleClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("click", this.handleClick);
|
||||
}
|
||||
|
||||
protected handleClick = (_e: Event): void => {};
|
||||
}
|
||||
|
||||
export class ProboAcceptButton extends ProboActionButton {
|
||||
protected handleClick = (): void => {
|
||||
if (!this.root) return;
|
||||
void this.root.client.acceptAll().then(() => {
|
||||
this.root!.setState("hidden");
|
||||
this.root!.dispatchEvent(
|
||||
new CustomEvent("probo-consent", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { action: "ACCEPT_ALL" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class ProboRejectButton extends ProboActionButton {
|
||||
protected handleClick = (): void => {
|
||||
if (!this.root) return;
|
||||
void this.root.client.rejectAll().then(() => {
|
||||
this.root!.setState("hidden");
|
||||
this.root!.dispatchEvent(
|
||||
new CustomEvent("probo-consent", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { action: "REJECT_ALL" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class ProboCustomizeButton extends ProboActionButton {
|
||||
protected handleClick = (): void => {
|
||||
if (!this.root) return;
|
||||
this.root.setState("panel");
|
||||
};
|
||||
}
|
||||
92
packages/cookie-banner/src/components/category-list.ts
Normal file
92
packages/cookie-banner/src/components/category-list.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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.
|
||||
|
||||
import type { Category } from "../client";
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
export class ProboCategoryList extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
private template: HTMLTemplateElement | null = null;
|
||||
private onReady = (e: Event): void => {
|
||||
const { config } = (e as CustomEvent).detail;
|
||||
this.stamp(config.categories);
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot name="items"></slot>
|
||||
`;
|
||||
|
||||
this.template = this.querySelector("template");
|
||||
if (!this.template) {
|
||||
this.warn("<probo-category-list> requires a <template> child");
|
||||
return;
|
||||
}
|
||||
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("-root");
|
||||
if (!this.root) return;
|
||||
|
||||
try {
|
||||
const config = this.root.bannerConfig;
|
||||
this.stamp(config.categories);
|
||||
} catch {
|
||||
this.root.addEventListener("probo-ready", this.onReady, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.root) {
|
||||
this.root.removeEventListener("probo-ready", this.onReady);
|
||||
}
|
||||
}
|
||||
|
||||
private stamp(categories: Category[]): void {
|
||||
if (!this.template) return;
|
||||
|
||||
for (const cat of categories) {
|
||||
const wrapper = document.createElement("probo-category");
|
||||
wrapper.setAttribute("name", cat.name);
|
||||
wrapper.setAttribute("slot", "items");
|
||||
if (cat.required) {
|
||||
wrapper.setAttribute("required", "");
|
||||
}
|
||||
wrapper.setAttribute("description", cat.description);
|
||||
wrapper.setAttribute("cookies", JSON.stringify(cat.cookies));
|
||||
|
||||
const clone = this.template.content.cloneNode(true) as DocumentFragment;
|
||||
this.fillSlots(clone, {
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
});
|
||||
|
||||
wrapper.appendChild(clone);
|
||||
this.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
private fillSlots(
|
||||
fragment: DocumentFragment,
|
||||
data: Record<string, string>,
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const els = fragment.querySelectorAll(`[data-slot="${key}"]`);
|
||||
for (const el of els) {
|
||||
el.textContent = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
packages/cookie-banner/src/components/category-toggle.ts
Normal file
85
packages/cookie-banner/src/components/category-toggle.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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.
|
||||
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCategory } from "./category";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
export class ProboCategoryToggle extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
private category: ProboCategory | null = null;
|
||||
private checkbox: HTMLInputElement | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-block; }
|
||||
</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
this.category = this.findAncestor<ProboCategory>("probo-category");
|
||||
|
||||
this.scheduleValidation(() => this.setup());
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.checkbox) {
|
||||
this.checkbox.removeEventListener("change", this.handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
private setup(): void {
|
||||
this.checkbox = this.querySelector<HTMLInputElement>("input[type=checkbox]");
|
||||
|
||||
if (!this.checkbox) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.part.add("toggle");
|
||||
this.appendChild(input);
|
||||
this.checkbox = input;
|
||||
}
|
||||
|
||||
if (!this.category || !this.root) return;
|
||||
|
||||
const name = this.category.categoryName;
|
||||
const isRequired = this.category.required;
|
||||
|
||||
if (isRequired) {
|
||||
this.checkbox.checked = true;
|
||||
this.checkbox.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = this.root.consentDraft;
|
||||
this.checkbox.checked = !!draft[name];
|
||||
this.checkbox.addEventListener("change", this.handleChange);
|
||||
|
||||
if (this.root) {
|
||||
this.root.addEventListener("probo-state", (e: Event) => {
|
||||
const { state } = (e as CustomEvent).detail;
|
||||
if (state === "panel" && this.checkbox && this.category && this.root) {
|
||||
this.checkbox.checked = !!this.root.consentDraft[this.category.categoryName];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleChange = (): void => {
|
||||
if (!this.checkbox || !this.category || !this.root) return;
|
||||
this.root.updateDraft(this.category.categoryName, this.checkbox.checked);
|
||||
};
|
||||
}
|
||||
41
packages/cookie-banner/src/components/category.ts
Normal file
41
packages/cookie-banner/src/components/category.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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.
|
||||
|
||||
import type { CookieItem } from "../client";
|
||||
import { ProboElement } from "./base";
|
||||
|
||||
export class ProboCategory extends ProboElement {
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
get categoryName(): string {
|
||||
return this.getAttribute("name") ?? "";
|
||||
}
|
||||
|
||||
get required(): boolean {
|
||||
return this.hasAttribute("required");
|
||||
}
|
||||
|
||||
get cookies(): CookieItem[] {
|
||||
try {
|
||||
return JSON.parse(this.getAttribute("cookies") ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
130
packages/cookie-banner/src/components/cookie-banner-root.ts
Normal file
130
packages/cookie-banner/src/components/cookie-banner-root.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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.
|
||||
|
||||
import { CookieBannerClient } from "../client";
|
||||
import type { BannerConfig } from "../client";
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboState, ProboRootElement, ConsentDraft } from "./base";
|
||||
|
||||
export class ProboCookieBannerRoot extends ProboElement implements ProboRootElement {
|
||||
private _client: CookieBannerClient | null = null;
|
||||
private _config: BannerConfig | null = null;
|
||||
private _state: ProboState = "loading";
|
||||
private _draft: ConsentDraft = {};
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return ["banner-id", "base-url"];
|
||||
}
|
||||
|
||||
get client(): CookieBannerClient {
|
||||
if (!this._client) {
|
||||
throw new Error("<probo-cookie-banner-root> not loaded yet");
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
|
||||
get bannerConfig(): BannerConfig {
|
||||
if (!this._config) {
|
||||
throw new Error("<probo-cookie-banner-root> not loaded yet");
|
||||
}
|
||||
return this._config;
|
||||
}
|
||||
|
||||
get state(): ProboState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get consentDraft(): ConsentDraft {
|
||||
return this._draft;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `<style>:host { display: contents; }</style><slot></slot>`;
|
||||
this.initClient();
|
||||
}
|
||||
|
||||
setState(state: ProboState): void {
|
||||
const prev = this._state;
|
||||
this._state = state;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("probo-state", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { state, prev },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateDraft(category: string, value: boolean): void {
|
||||
this._draft[category] = value;
|
||||
}
|
||||
|
||||
resetDraft(): void {
|
||||
if (!this._config) return;
|
||||
this._draft = this.buildDraft(this._config);
|
||||
}
|
||||
|
||||
private buildDraft(config: BannerConfig): ConsentDraft {
|
||||
const draft: ConsentDraft = {};
|
||||
const existing = this._client?.visitorConsent?.consent_data;
|
||||
|
||||
for (const cat of config.categories) {
|
||||
if (cat.required) {
|
||||
draft[cat.name] = true;
|
||||
} else if (existing && cat.name in existing) {
|
||||
draft[cat.name] = existing[cat.name];
|
||||
} else {
|
||||
draft[cat.name] = config.consent_mode === "OPT_OUT";
|
||||
}
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async initClient(): Promise<void> {
|
||||
const bannerId = this.getAttribute("banner-id");
|
||||
const baseUrl = this.getAttribute("base-url");
|
||||
|
||||
if (!bannerId || !baseUrl) {
|
||||
this.warn("<probo-cookie-banner-root> requires banner-id and base-url attributes");
|
||||
return;
|
||||
}
|
||||
|
||||
this._client = new CookieBannerClient({ bannerId, baseUrl });
|
||||
|
||||
try {
|
||||
await this._client.load();
|
||||
} catch (err) {
|
||||
this.warn(`failed to load banner config: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._config = this._client.config;
|
||||
this._draft = this.buildDraft(this._config);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("probo-ready", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { config: this._config },
|
||||
}),
|
||||
);
|
||||
|
||||
if (this._client.hasConsent) {
|
||||
this.setState("hidden");
|
||||
} else {
|
||||
this.setState("banner");
|
||||
}
|
||||
}
|
||||
}
|
||||
87
packages/cookie-banner/src/components/cookie-list.ts
Normal file
87
packages/cookie-banner/src/components/cookie-list.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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.
|
||||
|
||||
import type { CookieItem } from "../client";
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboCategory } from "./category";
|
||||
|
||||
export class ProboCookieList extends ProboElement {
|
||||
private category: ProboCategory | null = null;
|
||||
private template: HTMLTemplateElement | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot name="items"></slot>
|
||||
`;
|
||||
|
||||
this.template = this.querySelector("template");
|
||||
if (!this.template) {
|
||||
this.warn("<probo-cookie-list> requires a <template> child");
|
||||
return;
|
||||
}
|
||||
|
||||
this.category = this.findAncestor<ProboCategory>("probo-category");
|
||||
|
||||
this.scheduleValidation(() => this.stamp());
|
||||
}
|
||||
|
||||
private stamp(): void {
|
||||
if (!this.template || !this.category) return;
|
||||
|
||||
const cookies = this.category.cookies;
|
||||
for (const cookie of cookies) {
|
||||
this.stampCookie(cookie);
|
||||
}
|
||||
}
|
||||
|
||||
private stampCookie(cookie: CookieItem): void {
|
||||
if (!this.template) return;
|
||||
|
||||
const wrapper = document.createElement("probo-cookie");
|
||||
wrapper.setAttribute("name", cookie.name);
|
||||
wrapper.setAttribute("slot", "items");
|
||||
|
||||
const clone = this.template.content.cloneNode(true) as DocumentFragment;
|
||||
this.fillSlots(clone, {
|
||||
name: cookie.name,
|
||||
duration: cookie.duration,
|
||||
description: cookie.description,
|
||||
});
|
||||
|
||||
wrapper.appendChild(clone);
|
||||
this.appendChild(wrapper);
|
||||
}
|
||||
|
||||
private fillSlots(
|
||||
fragment: DocumentFragment,
|
||||
data: Record<string, string>,
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const els = fragment.querySelectorAll(`[data-slot="${key}"]`);
|
||||
for (const el of els) {
|
||||
el.textContent = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ProboCookie extends ProboElement {
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
30
packages/cookie-banner/src/components/index.ts
Normal file
30
packages/cookie-banner/src/components/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 { ProboElement } from "./base";
|
||||
export type { ProboState, ProboRootElement, ConsentDraft } from "./base";
|
||||
export { ProboBanner } from "./banner";
|
||||
export {
|
||||
ProboAcceptButton,
|
||||
ProboCustomizeButton,
|
||||
ProboRejectButton,
|
||||
} from "./buttons";
|
||||
export { ProboCategory } from "./category";
|
||||
export { ProboCategoryList } from "./category-list";
|
||||
export { ProboCategoryToggle } from "./category-toggle";
|
||||
export { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
export { ProboCookie, ProboCookieList } from "./cookie-list";
|
||||
export { ProboPreferencePanel, ProboSaveButton } from "./preference-panel";
|
||||
export { ProboSettingsButton } from "./settings-button";
|
||||
export { registerComponents } from "./register";
|
||||
86
packages/cookie-banner/src/components/preference-panel.ts
Normal file
86
packages/cookie-banner/src/components/preference-panel.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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.
|
||||
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
export class ProboPreferencePanel extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
private onStateChange = (e: Event): void => {
|
||||
const { state } = (e as CustomEvent).detail;
|
||||
this.hidden = state !== "panel";
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
:host([hidden]) { display: none; }
|
||||
</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
|
||||
this.hidden = true;
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
|
||||
if (this.root) {
|
||||
this.root.addEventListener("probo-state", this.onStateChange);
|
||||
this.root.addEventListener("probo-state", (e: Event) => {
|
||||
const { state } = (e as CustomEvent).detail;
|
||||
if (state === "panel") {
|
||||
(this.root as ProboCookieBannerRoot).resetDraft();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.root) {
|
||||
this.root.removeEventListener("probo-state", this.onStateChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ProboSaveButton extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.shadow.innerHTML = `
|
||||
<style>:host { display: contents; }</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
this.addEventListener("click", this.handleClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("click", this.handleClick);
|
||||
}
|
||||
|
||||
private handleClick = (): void => {
|
||||
if (!this.root) return;
|
||||
const draft = { ...this.root.consentDraft };
|
||||
void this.root.client.customize(draft).then(() => {
|
||||
this.root!.setState("hidden");
|
||||
this.root!.dispatchEvent(
|
||||
new CustomEvent("probo-consent", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { action: "CUSTOMIZE", consent_data: draft },
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
51
packages/cookie-banner/src/components/register.ts
Normal file
51
packages/cookie-banner/src/components/register.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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.
|
||||
|
||||
import { ProboBanner } from "./banner";
|
||||
import {
|
||||
ProboAcceptButton,
|
||||
ProboCustomizeButton,
|
||||
ProboRejectButton,
|
||||
} from "./buttons";
|
||||
import { ProboCategory } from "./category";
|
||||
import { ProboCategoryList } from "./category-list";
|
||||
import { ProboCategoryToggle } from "./category-toggle";
|
||||
import { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
import { ProboCookie, ProboCookieList } from "./cookie-list";
|
||||
import { ProboPreferencePanel, ProboSaveButton } from "./preference-panel";
|
||||
import { ProboSettingsButton } from "./settings-button";
|
||||
|
||||
const elements: [string, CustomElementConstructor][] = [
|
||||
["probo-cookie-banner-root", ProboCookieBannerRoot],
|
||||
["probo-banner", ProboBanner],
|
||||
["probo-accept-button", ProboAcceptButton],
|
||||
["probo-reject-button", ProboRejectButton],
|
||||
["probo-customize-button", ProboCustomizeButton],
|
||||
["probo-preference-panel", ProboPreferencePanel],
|
||||
["probo-category-list", ProboCategoryList],
|
||||
["probo-category", ProboCategory],
|
||||
["probo-category-toggle", ProboCategoryToggle],
|
||||
["probo-cookie-list", ProboCookieList],
|
||||
["probo-cookie", ProboCookie],
|
||||
["probo-save-button", ProboSaveButton],
|
||||
["probo-settings-button", ProboSettingsButton],
|
||||
];
|
||||
|
||||
export function registerComponents(): void {
|
||||
for (const [name, ctor] of elements) {
|
||||
if (!customElements.get(name)) {
|
||||
customElements.define(name, ctor);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
packages/cookie-banner/src/components/settings-button.ts
Normal file
100
packages/cookie-banner/src/components/settings-button.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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.
|
||||
|
||||
import { ProboElement } from "./base";
|
||||
import type { ProboRootElement } from "./base";
|
||||
import type { ProboCookieBannerRoot } from "./cookie-banner-root";
|
||||
|
||||
const COOKIE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="8" cy="9" r="1" fill="currentColor"/><circle cx="15" cy="11" r="1" fill="currentColor"/><circle cx="10" cy="15" r="1" fill="currentColor"/><circle cx="13" cy="7" r="1" fill="currentColor"/></svg>`;
|
||||
|
||||
export class ProboSettingsButton extends ProboElement {
|
||||
private root: ProboRootElement | null = null;
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return ["position"];
|
||||
}
|
||||
|
||||
private get position(): string {
|
||||
return this.getAttribute("position") ?? "bottom-left";
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const pos = this.position;
|
||||
const isRight = pos === "bottom-right";
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
position: fixed;
|
||||
bottom: var(--probo-settings-bottom, 16px);
|
||||
${isRight ? "right" : "left"}: var(--probo-settings-offset, 16px);
|
||||
z-index: var(--probo-settings-z-index, 2147483645);
|
||||
}
|
||||
:host([hidden]) { display: none; }
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
border-radius: var(--probo-settings-radius, 9999px);
|
||||
background: var(--probo-settings-bg, #1a1a1a);
|
||||
color: var(--probo-settings-color, #ffffff);
|
||||
padding: var(--probo-settings-padding, 10px);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--probo-settings-font-size, 14px);
|
||||
box-shadow: var(--probo-settings-shadow, 0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
button:hover { opacity: 0.85; }
|
||||
.icon { display: flex; flex-shrink: 0; }
|
||||
::slotted(*) { display: contents; }
|
||||
</style>
|
||||
<button part="button">
|
||||
<span class="icon" part="icon">${COOKIE_ICON}</span>
|
||||
<slot></slot>
|
||||
</button>
|
||||
`;
|
||||
|
||||
this.hidden = true;
|
||||
this.root = this.findAncestor<ProboCookieBannerRoot>("probo-cookie-banner-root");
|
||||
|
||||
if (this.root) {
|
||||
this.root.addEventListener("probo-state", this.onStateChange);
|
||||
if (this.root.state === "hidden") {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = this.shadow.querySelector("button");
|
||||
btn?.addEventListener("click", this.handleClick);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.root) {
|
||||
this.root.removeEventListener("probo-state", this.onStateChange);
|
||||
}
|
||||
}
|
||||
|
||||
private onStateChange = (e: Event): void => {
|
||||
const { state } = (e as CustomEvent).detail;
|
||||
this.hidden = state !== "hidden";
|
||||
};
|
||||
|
||||
private handleClick = (): void => {
|
||||
if (!this.root) return;
|
||||
this.root.setState("panel");
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,24 @@ export type {
|
||||
CookieItem,
|
||||
VisitorConsent,
|
||||
} from "./client";
|
||||
export {
|
||||
ProboElement,
|
||||
ProboBanner,
|
||||
ProboAcceptButton,
|
||||
ProboRejectButton,
|
||||
ProboCustomizeButton,
|
||||
ProboCookieBannerRoot,
|
||||
ProboPreferencePanel,
|
||||
ProboSaveButton,
|
||||
ProboCategoryList,
|
||||
ProboCategory,
|
||||
ProboCategoryToggle,
|
||||
ProboCookieList,
|
||||
ProboCookie,
|
||||
ProboSettingsButton,
|
||||
registerComponents,
|
||||
} from "./components";
|
||||
export type { ProboState, ProboRootElement, ConsentDraft } from "./components";
|
||||
export type { ConsentCookie } from "./cookie";
|
||||
export {
|
||||
ApiError,
|
||||
@@ -36,3 +54,6 @@ export {
|
||||
} from "./errors";
|
||||
export { fetchJSON } from "./http";
|
||||
export type { RequestOptions } from "./http";
|
||||
|
||||
import { registerComponents } from "./components";
|
||||
registerComponents();
|
||||
|
||||
Reference in New Issue
Block a user