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:
Émile Ré
2026-04-16 14:54:46 +04:00
parent 82811f3b0b
commit d802ee611f
13 changed files with 943 additions and 0 deletions

View 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);
}
}
}

View 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;
}

View 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");
};
}

View 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;
}
}
}
}

View 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);
};
}

View 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 [];
}
}
}

View 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");
}
}
}

View 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>
`;
}
}

View 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";

View 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 },
}),
);
});
};
}

View 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);
}
}
}

View 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");
};
}

View File

@@ -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();