From d802ee611f38cbdf219ef9dfff0e50c117f869e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 16 Apr 2026 14:54:46 +0400 Subject: [PATCH] Add headless web components for cookie banner UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- .../cookie-banner/src/components/banner.ts | 72 ++++++++++ packages/cookie-banner/src/components/base.ts | 73 ++++++++++ .../cookie-banner/src/components/buttons.ts | 75 ++++++++++ .../src/components/category-list.ts | 92 +++++++++++++ .../src/components/category-toggle.ts | 85 ++++++++++++ .../cookie-banner/src/components/category.ts | 41 ++++++ .../src/components/cookie-banner-root.ts | 130 ++++++++++++++++++ .../src/components/cookie-list.ts | 87 ++++++++++++ .../cookie-banner/src/components/index.ts | 30 ++++ .../src/components/preference-panel.ts | 86 ++++++++++++ .../cookie-banner/src/components/register.ts | 51 +++++++ .../src/components/settings-button.ts | 100 ++++++++++++++ packages/cookie-banner/src/index.ts | 21 +++ 13 files changed, 943 insertions(+) create mode 100644 packages/cookie-banner/src/components/banner.ts create mode 100644 packages/cookie-banner/src/components/base.ts create mode 100644 packages/cookie-banner/src/components/buttons.ts create mode 100644 packages/cookie-banner/src/components/category-list.ts create mode 100644 packages/cookie-banner/src/components/category-toggle.ts create mode 100644 packages/cookie-banner/src/components/category.ts create mode 100644 packages/cookie-banner/src/components/cookie-banner-root.ts create mode 100644 packages/cookie-banner/src/components/cookie-list.ts create mode 100644 packages/cookie-banner/src/components/index.ts create mode 100644 packages/cookie-banner/src/components/preference-panel.ts create mode 100644 packages/cookie-banner/src/components/register.ts create mode 100644 packages/cookie-banner/src/components/settings-button.ts diff --git a/packages/cookie-banner/src/components/banner.ts b/packages/cookie-banner/src/components/banner.ts new file mode 100644 index 000000000..0f034718f --- /dev/null +++ b/packages/cookie-banner/src/components/banner.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 = ` + + + `; + + this.hidden = true; + this.root = this.findAncestor("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(` is missing required children: ${missing.join(", ")}`); + this.emitValidation(missing); + } + } +} diff --git a/packages/cookie-banner/src/components/base.ts b/packages/cookie-banner/src/components/base.ts new file mode 100644 index 000000000..bbd9a26cc --- /dev/null +++ b/packages/cookie-banner/src/components/base.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(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; +} diff --git a/packages/cookie-banner/src/components/buttons.ts b/packages/cookie-banner/src/components/buttons.ts new file mode 100644 index 000000000..f542d18f8 --- /dev/null +++ b/packages/cookie-banner/src/components/buttons.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 = ` + + + `; + this.root = this.findAncestor("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"); + }; +} diff --git a/packages/cookie-banner/src/components/category-list.ts b/packages/cookie-banner/src/components/category-list.ts new file mode 100644 index 000000000..0c77e9c31 --- /dev/null +++ b/packages/cookie-banner/src/components/category-list.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 = ` + + + `; + + this.template = this.querySelector("template"); + if (!this.template) { + this.warn(" requires a