diff --git a/apps/documentation/pages/docs/design/components/_meta.json b/apps/documentation/pages/docs/design/components/_meta.json index e0ff1ffc..1e43cd57 100644 --- a/apps/documentation/pages/docs/design/components/_meta.json +++ b/apps/documentation/pages/docs/design/components/_meta.json @@ -14,5 +14,7 @@ "multi-select": "Multi-Select", "pagination": "Pagination", "tabs": "Tabs", - "table": "Table" -} \ No newline at end of file + "table": "Table", + "cookie-consent": "Cookie Consent", + "compliance-footer": "Compliance Footer" +} diff --git a/apps/documentation/pages/docs/design/components/compliance-footer.mdx b/apps/documentation/pages/docs/design/components/compliance-footer.mdx new file mode 100644 index 00000000..0a8ed938 --- /dev/null +++ b/apps/documentation/pages/docs/design/components/compliance-footer.mdx @@ -0,0 +1,130 @@ +import { Callout, Tabs } from 'nextra/components' +import ComponentName from '@/components/common/ComponentName' +import ComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; + +# EccUtilsDesignComplianceFooter Component + + + +## Overview + +The `ecc-utils-design-compliance-footer` component renders the links to legal and regulatory information that operators must provide, configured per deployment. It reads the same configuration as the [cookie consent](/docs/design/components/cookie-consent), so an app adds both from configuration alone. + +Each item has an action: + +| Action | Configuration | Result | +|---------------|-------------------------------------------------------|------------------------------------------------------------| +| `link` | `{ type: 'link', href, newTab? }` | A link (`http(s)` or relative). | +| `mailto` | `{ type: 'mailto', email, subject? }` | Opens the mail client; `email` is a plain address (letters, digits, `.`, `_`, `+`, `-`). | +| `preferences` | `{ type: 'preferences' }` | Opens the cookie preferences. | +| `page` | `{ type: 'page', markdown, title? }` | Shows a page from Markdown in a dialog. | +| `embed` | `{ type: 'embed', src, title?, consent? }` | Shows an `https` page in a frame in a dialog, e.g. a policy hosted by Iubenda, Termly or GetTerms; with `consent`, only once users consented to that cookie category. | + + + Operators are responsible for the content: which items their deployment needs, under which regulations, and what the pages say. The defaults are only a starting point. + + +## Usage + + + + + + + ```tsx showLineNumbers copy filename="footer.tsx" + import EccUtilsDesignComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; + + const compliance = { + footer: { + items: [ + { id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }, + { id: 'imprint', action: { type: 'page', markdown: 'Operated by **Example Institute**.' } }, + { id: 'contact', action: { type: 'mailto', email: 'support@example.org' } }, + { id: 'accessibility', hidden: true }, + ], + }, + }; + + export default function Footer() { + return ; + } + ``` + + + ```html showLineNumbers copy filename="footer.html" + + + + ``` + + + +## Properties + +| Property | Type | Default | Description | +|----------|--------------------|---------|------------------------------------------------------------------------------| +| `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.footer`, and `config.cookies` for embeds that need consent. | + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|--------------------------------|--------------------------------|------------------------|-------------| +| `ecc-compliance-item-selected` | `onEccComplianceItemSelected` | `{ item: FooterItem }` | Fired when the user selects an item, before its action runs. Call `event.preventDefault()` to handle the item yourself, e.g. with the app's router. | + +## Configuration (`config.footer`) + +| Field | Type | Description | +|-------------------|----------------------|-------------------------------------------------------------------------------| +| `items` | `FooterItemConfig[]` | `{ id, label?, action?, hidden? }`. An entry with the `id` of a default item changes it (`label`, `action`) or removes it (`hidden: true`); other entries are added after the defaults, in their order. | +| `replaceDefaults` | `boolean` | Use only the configured items. | +| `label` | `string` | Accessible name of the footer's navigation, default "Legal". | +| `texts` | `{ close?: string }` | Texts of the footer's dialog, e.g. for translations; `close` defaults to `config.cookies.texts.close`, then "Close". | + +Items without an action, or whose link, address or embed URL isn't valid, aren't shown. + +### Default items + +| ID | Label | Default action | +|----------------------|-------------------------|-----------------| +| `privacy-policy` | Privacy Policy | none | +| `terms` | Terms & Conditions | none | +| `cookie-policy` | Cookie Policy | none | +| `cookie-preferences` | Cookie Preferences | `preferences` | +| `imprint` | Imprint | none | +| `accessibility` | Accessibility Statement | none | +| `contact` | Contact Us | none | +| `privacy-choices` | Your Privacy Choices | none | + +So without configuration, the footer shows only "Cookie Preferences"; each item that the operator gives an action appears, in this order. "Cookie Preferences" opens the preferences of the [cookie consent](/docs/design/components/cookie-consent) component, so add that component to the page too, or hide the item (`{ id: 'cookie-preferences', hidden: true }`). + +### Markdown pages + +`page` items support a small subset of Markdown: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, `mailto` and relative URLs; links to other origins open in a new tab). All text is escaped; HTML in the Markdown is shown as text, not rendered. + +### Embeds + +- Only `https` URLs (and `http` on `localhost`) are embedded. The frame is sandboxed (`allow-scripts allow-same-origin allow-popups`, so pages it opens stay sandboxed; without `allow-same-origin` for pages of the app's own origin, which couldn't be isolated otherwise) and sends no referrer. For pages of your own, prefer `page` or `link`. +- Allow the embed's origin in the app's Content Security Policy (`frame-src`). +- An embed loads its frame when users open the item, whether or not they chose their cookies yet. Embeds whose provider sets cookies or tracks visitors (check the provider's documentation) must therefore name a cookie category in `consent`; until users consent to it, the dialog shows a note and a button to the preferences instead of the frame. +- Script-based embeds (a ` + + + ``` + + + +## Properties + +| Property | Type | Default | Description | +|----------|--------------------|---------|-----------------------------------------------------------------------------| +| `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.cookies`. A new object with the same categories, `version`, `storageKey` and `notice` keeps the choices users haven't saved yet, so it may be created on every render. | + +## Methods + +| Method | Description | +|---------------------|--------------------------------------------------------------------| +| `openPreferences()` | Opens the preferences. `openCookiePreferences()` does the same from anywhere on the page. | + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|------------------------------|-----------------------------|------------------------------|----------------------------------------------| +| `ecc-cookie-consent-changed` | `onEccCookieConsentChanged` | `{ consent: CookieConsent }` | Fired when the user saved their choices. It doesn't bubble; `saveConsent()` fires the same event on `window`. | + +## Configuration (`config.cookies`) + +| Field | Type | Description | +|--------------|-------------------------------|-----------------------------------------------------------------------------| +| `categories` | `CookieCategory[]` | `{ id, label, description?, required? }`. A required "necessary" category is added if none is required. | +| `version` | `string` | Version of the cookie policy; changing it asks users again. | +| `policyUrl` | `string` | Link to the cookie policy (`http(s)` or relative), shown in the banner and the preferences. | +| `notice` | `boolean` | Show the banner although there are no optional categories. | +| `storageKey` | `string` | `localStorage` key, default `ecc-cookie-consent`. | +| `texts` | `Partial` | Texts of the banner and the preferences, e.g. for translations: `bannerTitle`, `bannerText`, `policyLink`, `acceptAll`, `rejectAll`, `customize`, `preferencesTitle`, `preferencesText`, `save`, `close`, `alwaysOn`, `embedConsent`. | + +Changing a category's `id` or `required`, adding or removing a category, or changing `version` asks users again; changing texts or descriptions doesn't. + +## Loading scripts with consent + +The functions below come from `@elixir-cloud/design/components/cookie-consent/index.js` (also exported by the package's main entry). Pass the same `cookies` configuration that the component uses. + +```ts copy +import { + hasConsent, + onConsentChange, + openCookiePreferences, +} from '@elixir-cloud/design/components/cookie-consent/index.js'; + +const loadAnalytics = () => { /* add the analytics script */ }; + +if (hasConsent('analytics', compliance.cookies)) loadAnalytics(); +onConsentChange((consent) => { + if (consent?.categories.analytics) loadAnalytics(); + // revoking consent: stop tracking and remove its cookies, or reload +}, compliance.cookies); + +// e.g. from a link of your own +openCookiePreferences(); +``` + +| Function | Description | +|----------------------------------------|-----------------------------------------------------------------| +| `hasConsent(category, config?)` | Whether users consented to a category (required ones: always). | +| `readConsent(config?)` | The stored `CookieConsent`, or `null` if users must be asked. | +| `saveConsent(config, choices)` | Stores choices and fires `ecc-cookie-consent-changed` on `window`. | +| `onConsentChange(listener, config?)` | Calls `listener` on every change for the storage key of `config`, in this page and in other tabs (with `null` if that consent was removed); returns a function to stop. | +| `openCookiePreferences()` | Asks the page's component to open the preferences. | + +## Notes for operators + +- List every category that your deployment uses, with a description users understand. Keep "Necessary" for what the site can't work without (e.g. login cookies); these need no consent under the GDPR and ePrivacy rules, but should be explained in the cookie policy. +- Bump `version` when the cookie policy changes in a way users should see. +- Provide the cookie policy (`policyUrl`, or a footer item) and load optional scripts only after `hasConsent()`. +- Consent is stored per browser and origin; it isn't sent to a server. If the browser can't store it, it applies to the current page only. diff --git a/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts b/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts new file mode 100644 index 00000000..97a2f34c --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts @@ -0,0 +1,246 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { + ComplianceConfig, + cookieCategories, + DEFAULT_COOKIE_TEXTS, + FooterItem, + footerItems, + hasConsent, + onConsentChange, + openCookiePreferences, +} from "../cookie-consent/consent.js"; +import { + isSameOrigin, + mailtoHref, + safeEmbedSrc, + safeHref, +} from "../cookie-consent/urls.js"; +import { renderMarkdown } from "./markdown.js"; +import "../button/index.js"; + +type DialogItem = FooterItem & { + action: Extract; +}; + +/** + * @summary Footer with links to legal and regulatory information, configured + * by the shared compliance configuration. + * @since 2.0.0 + * + * @property {ComplianceConfig} config - Shared compliance configuration; the component uses `config.footer` (and `config.cookies` for embeds that need consent). + * + * @fires ecc-compliance-item-selected - Fired when the user selects an item (detail: `{ item }`), before its action runs. Cancel the event (`preventDefault()`) to handle the item yourself, e.g. with the app's router. + * + * @csspart nav - The navigation with the items. + * @csspart item - Each item. + * @csspart dialog - The dialog of pages and embeds. + */ +export class EccUtilsDesignComplianceFooter extends LitElement { + static styles = [ + css` + ${TailwindStyles} + `, + css` + ${GlobalStyles} + `, + css` + :host { + display: block; + } + dialog::backdrop { + background: rgb(0 0 0 / 0.5); + } + `, + ]; + + @property({ attribute: false }) config: ComplianceConfig = {}; + + /** The page or embed shown in the dialog. */ + @state() private openItem: DialogItem | null = null; + + @query("dialog") private dialog?: HTMLDialogElement; + + private stopListening?: () => void; + + connectedCallback(): void { + super.connectedCallback(); + this.listen(); + } + + disconnectedCallback(): void { + this.stopListening?.(); + this.stopListening = undefined; + super.disconnectedCallback(); + } + + protected willUpdate(changed: Map): void { + if (changed.has("config") && this.isConnected) this.listen(); + } + + /** Renders again when consent changes, so that embeds load once allowed. */ + private listen(): void { + this.stopListening?.(); + this.stopListening = onConsentChange( + () => this.requestUpdate(), + this.config?.cookies + ); + } + + private select(item: FooterItem, event?: Event): boolean { + const selected = new CustomEvent("ecc-compliance-item-selected", { + detail: { item }, + bubbles: true, + composed: true, + cancelable: true, + }); + const proceed = this.dispatchEvent(selected); + if (!proceed) event?.preventDefault(); + return proceed; + } + + private async openDialog(item: DialogItem): Promise { + this.openItem = item; + await this.updateComplete; + if (this.isConnected && this.dialog && !this.dialog.open) { + this.dialog.showModal(); + } + } + + private runAction(item: FooterItem): void { + const { action } = item; + if (!action || !this.select(item)) return; + if (action.type === "preferences") openCookiePreferences(); + else if (action.type === "page" || action.type === "embed") { + this.openDialog(item as DialogItem); + } + } + + private renderItem(item: FooterItem) { + const { action } = item; + const linkClasses = + "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"; + if (action?.type === "link" || action?.type === "mailto") { + const href = + action.type === "link" + ? safeHref(action.href) + : mailtoHref(action.email, action.subject); + if (!href) return nothing; + const newTab = action.type === "link" && action.newTab; + return html`
  • + this.select(item, event)} + >${item.label} +
  • `; + } + if (action?.type === "embed" && !safeEmbedSrc(action.src)) return nothing; + return html`
  • + +
  • `; + } + + private renderEmbed(item: DialogItem) { + if (item.action.type !== "embed") return nothing; + const { consent } = item.action; + const src = safeEmbedSrc(item.action.src); + if (!src) return nothing; + // checked on every render; consent changes request one + if (consent && !hasConsent(consent, this.config?.cookies)) { + const texts = { ...DEFAULT_COOKIE_TEXTS, ...this.config?.cookies?.texts }; + const label = + cookieCategories(this.config?.cookies).find((c) => c.id === consent) + ?.label ?? consent; + return html`

    + ${texts.embedConsent.replace("{category}", label)} +

    + openCookiePreferences()} + >${texts.preferencesTitle}`; + } + // a same-origin frame with both flags could lift its own sandbox + const sandbox = isSameOrigin(src) + ? "allow-scripts allow-popups" + : "allow-scripts allow-same-origin allow-popups"; + return html``; + } + + private renderDialog() { + const item = this.openItem; + const close = + this.config?.footer?.texts?.close ?? + this.config?.cookies?.texts?.close ?? + DEFAULT_COOKIE_TEXTS.close; + return html` + { + this.openItem = null; + }} + > + ${item + ? html`

    + ${item.action.title ?? item.label} +

    +
    + ${item.action.type === "page" + ? renderMarkdown(item.action.markdown) + : this.renderEmbed(item)} +
    ` + : nothing} +
    + this.dialog?.close()} + >${close} +
    +
    + `; + } + + render() { + const items = footerItems(this.config?.footer); + return html` + + ${this.renderDialog()} + `; + } +} + +export default EccUtilsDesignComplianceFooter; diff --git a/packages/ecc-utils-design/src/components/compliance-footer/index.ts b/packages/ecc-utils-design/src/components/compliance-footer/index.ts new file mode 100644 index 00000000..2d681a03 --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/index.ts @@ -0,0 +1,22 @@ +import EccUtilsDesignComplianceFooter from "./compliance-footer.js"; + +export * from "./compliance-footer.js"; +export default EccUtilsDesignComplianceFooter; + +// Define the custom element +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-utils-design-compliance-footer") +) { + window.customElements.define( + "ecc-utils-design-compliance-footer", + EccUtilsDesignComplianceFooter + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ecc-utils-design-compliance-footer": EccUtilsDesignComplianceFooter; + } +} diff --git a/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts b/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts new file mode 100644 index 00000000..0847f816 --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts @@ -0,0 +1,132 @@ +import { html, nothing, TemplateResult } from "lit"; +import { isExternal, safeHref } from "../cookie-consent/urls.js"; + +/* + * A small Markdown subset for legal pages, rendered with Lit templates, so + * that all text is escaped and no HTML from the configuration reaches the + * page: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), + * `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, + * `mailto` and relative URLs; links to other origins open in a new tab). + */ + +type Inline = string | TemplateResult; + +const INLINE = /\*\*([^*]+)\*\*|\*([^*]+)\*|\[([^\]]+)\]\(([^)\s]+)\)/g; + +/** + * Renders the inline Markdown of a line. + * @param text Text with inline Markdown + * @returns Strings and templates + */ +export function renderInline(text: string): Inline[] { + const parts: Inline[] = []; + let last = 0; + text.replace( + INLINE, + (match, bold, em, label, url, offset: number): string => { + if (offset > last) parts.push(text.slice(last, offset)); + if (bold) parts.push(html`${bold}`); + else if (em) parts.push(html`${em}`); + else { + const href = safeHref(url); + const external = !!href && isExternal(href); + parts.push( + href + ? html`${label}` + : label + ); + } + last = offset + match.length; + return match; + } + ); + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +const HEADING_CLASSES = [ + "mt-4 text-xl font-semibold", + "mt-4 text-lg font-semibold", + "mt-3 font-semibold", +]; + +/** + * Renders Markdown (the subset above). + * @param markdown The Markdown + * @returns The rendered blocks + */ +export function renderMarkdown(markdown: string): TemplateResult[] { + const blocks: TemplateResult[] = []; + let paragraph: string[] = []; + let list: { ordered: boolean; items: string[] } | null = null; + + const flushParagraph = () => { + if (!paragraph.length) return; + blocks.push(html`

    ${renderInline(paragraph.join(" "))}

    `); + paragraph = []; + }; + + const flushList = () => { + if (list) { + const items = list.items.map( + (item) => html`
  • ${renderInline(item)}
  • ` + ); + blocks.push( + list.ordered + ? html`
      + ${items} +
    ` + : html`
      + ${items} +
    ` + ); + list = null; + } + }; + + const flush = () => { + flushParagraph(); + flushList(); + }; + + (typeof markdown === "string" ? markdown : "") + .split(/\r?\n/) + .forEach((raw) => { + const line = raw.trim(); + const heading = /^(#{1,3})\s+(.*)$/.exec(line); + const bullet = /^[-*]\s+(.*)$/.exec(line); + const numbered = /^\d+[.)]\s+(.*)$/.exec(line); + if (!line) { + flush(); + } else if (heading) { + flush(); + const level = heading[1].length; + const content = renderInline(heading[2]); + const classes = HEADING_CLASSES[level - 1]; + // h2 to h4: the dialog's title is the h2 of the page + if (level === 1) + blocks.push(html`

    ${content}

    `); + else if (level === 2) + blocks.push(html`

    ${content}

    `); + else blocks.push(html`
    ${content}
    `); + } else if (bullet || numbered) { + flushParagraph(); + const ordered = !!numbered; + if (list && list.ordered !== ordered) flushList(); + if (!list) list = { ordered, items: [] }; + const item = bullet ?? numbered; + if (item) list.items.push(item[1]); + } else { + flushList(); + paragraph.push(line); + } + }); + flush(); + return blocks.length ? blocks : [html`${nothing}`]; +} diff --git a/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts b/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts new file mode 100644 index 00000000..c056e625 --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts @@ -0,0 +1,337 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import { render } from "lit"; +import sinon from "sinon"; +import "../index.js"; +import type EccUtilsDesignComplianceFooter from "../compliance-footer.js"; +import { + ComplianceConfig, + PREFERENCES_REQUESTED_EVENT, + footerItems, + saveConsent, +} from "../../cookie-consent/consent.js"; +import { renderMarkdown } from "../markdown.js"; + +const items = (el: EccUtilsDesignComplianceFooter) => + Array.from(el.shadowRoot?.querySelectorAll("[part=item]") ?? []); + +const item = (el: EccUtilsDesignComplianceFooter, label: string) => + items(el) + .map((li) => li.querySelector("a, button") as HTMLElement) + .find((node) => node.textContent?.trim() === label) as HTMLElement; + +const dialog = (el: EccUtilsDesignComplianceFooter) => + el.shadowRoot?.querySelector("dialog") as HTMLDialogElement; + +const COOKIES = { + categories: [{ id: "embeds", label: "Embedded content" }], +}; + +describe("footer items", () => { + it("shows only the defaults that have an action", () => { + expect(footerItems().map((i) => i.id)).to.deep.equal([ + "cookie-preferences", + ]); + }); + + it("changes, hides and adds items", () => { + const result = footerItems({ + items: [ + { + id: "privacy-policy", + action: { type: "link", href: "https://example.org/privacy" }, + }, + { id: "cookie-preferences", hidden: true }, + { + id: "status", + label: "Status", + action: { type: "link", href: "/status" }, + }, + { + id: "imprint", + label: "Impressum", + action: { type: "page", markdown: "# Impressum" }, + }, + ], + }); + + expect(result.map((i) => [i.id, i.label])).to.deep.equal([ + ["privacy-policy", "Privacy Policy"], + ["imprint", "Impressum"], + ["status", "Status"], + ]); + }); + + it("uses only configured items when asked", () => { + const result = footerItems({ + replaceDefaults: true, + items: [ + { id: "a", label: "A", action: { type: "preferences" } }, + { id: "b", label: "B" }, + ], + }); + + expect(result.map((i) => i.id)).to.deep.equal(["a"]); + }); +}); + +describe("markdown pages", () => { + const rendered = (markdown: string) => { + const container = document.createElement("div"); + render(renderMarkdown(markdown), container); + return container; + }; + + it("renders headings, paragraphs, lists and links", () => { + const container = rendered( + "# Privacy\n\nWe keep **little** data,\nfor *you*.\n\n- one\n- two\n\n1. first\n\nMail [us](mailto:dpo@example.org)." + ); + + expect(container.querySelector("h3")?.textContent).to.equal("Privacy"); + expect(container.querySelector("p strong")?.textContent).to.equal("little"); + expect(container.querySelector("p em")?.textContent).to.equal("you"); + expect(container.querySelectorAll("ul li")).to.have.length(2); + expect(container.querySelectorAll("ol li")).to.have.length(1); + expect(container.querySelector("a")?.getAttribute("href")).to.equal( + "mailto:dpo@example.org" + ); + }); + + it("opens only links to other origins in a new tab", () => { + const container = rendered( + "[ours](/terms) and [theirs](https://other.example.org/)" + ); + const [ours, theirs] = Array.from(container.querySelectorAll("a")); + + expect(ours.hasAttribute("target")).to.equal(false); + expect(theirs.getAttribute("target")).to.equal("_blank"); + expect(theirs.getAttribute("rel")).to.equal("noopener noreferrer"); + }); + + it("escapes HTML and drops unsafe links", () => { + const container = rendered( + ' [click](javascript:alert(1))' + ); + + expect(container.querySelector("img")).to.equal(null); + expect(container.querySelector("a")).to.equal(null); + expect(container.textContent).to.contain(" { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("renders links, mail and preference items", async () => { + const config: ComplianceConfig = { + footer: { + items: [ + { + id: "terms", + action: { + type: "link", + href: "https://example.org/terms", + newTab: true, + }, + }, + { + id: "contact", + action: { + type: "mailto", + email: "help@example.org", + subject: "Hi there", + }, + }, + ], + }, + }; + const el = await fixture( + html`` + ); + + expect(item(el, "Terms & Conditions").getAttribute("href")).to.equal( + "https://example.org/terms" + ); + expect(item(el, "Terms & Conditions").getAttribute("target")).to.equal( + "_blank" + ); + expect(item(el, "Contact Us").getAttribute("href")).to.equal( + "mailto:help@example.org?subject=Hi%20there" + ); + expect( + el.shadowRoot?.querySelector("nav")?.getAttribute("aria-label") + ).to.equal("Legal"); + + const requested = sinon.spy(); + window.addEventListener(PREFERENCES_REQUESTED_EVENT, requested); + item(el, "Cookie Preferences").click(); + window.removeEventListener(PREFERENCES_REQUESTED_EVENT, requested); + expect(requested.callCount).to.equal(1); + }); + + it("leaves out items with unsafe targets", async () => { + const el = await fixture( + html`` + ); + + expect(items(el)).to.have.length(1); // cookie preferences + }); + + it("shows a Markdown page in a dialog", async () => { + const el = await fixture( + html`` + ); + + item(el, "Imprint").click(); + await waitUntil(() => dialog(el).open); + + expect(dialog(el).querySelector("h2")?.textContent?.trim()).to.equal( + "Imprint" + ); + expect(dialog(el).querySelector("strong")?.textContent).to.equal("Example"); + dialog(el).close(); + }); + + it("lets apps handle items themselves", async () => { + const el = await fixture( + html` + event.preventDefault()} + >` + ); + + const selected = oneEvent(el, "ecc-compliance-item-selected"); + item(el, "Imprint").click(); + const { detail } = await selected; + await el.updateComplete; + + expect(detail.item.id).to.equal("imprint"); + expect(dialog(el).open).to.equal(false); + }); + + it("loads embeds only with consent", async () => { + const config: ComplianceConfig = { + cookies: COOKIES, + footer: { + items: [ + { + id: "privacy-policy", + action: { + type: "embed", + src: "https://policies.example.org/privacy", + consent: "embeds", + }, + }, + ], + }, + }; + const el = await fixture( + html`` + ); + + item(el, "Privacy Policy").click(); + await waitUntil(() => dialog(el).open); + expect(dialog(el).querySelector("iframe")).to.equal(null); + expect(dialog(el).textContent).to.contain("Embedded content"); + + saveConsent(COOKIES, { embeds: true }); + await el.updateComplete; + const frame = dialog(el).querySelector("iframe"); + expect(frame?.getAttribute("src")).to.equal( + "https://policies.example.org/privacy" + ); + expect(frame?.getAttribute("sandbox")).to.contain("allow-scripts"); + expect(frame?.getAttribute("sandbox")).not.to.contain( + "allow-popups-to-escape-sandbox" + ); + dialog(el).close(); + }); + + it("doesn't let same-origin embeds lift the sandbox", async () => { + const config: ComplianceConfig = { + footer: { + texts: { close: "Schliessen" }, + items: [ + { id: "terms", action: { type: "embed", src: "/legal/terms" } }, + ], + }, + }; + const el = await fixture( + html`` + ); + + item(el, "Terms & Conditions").click(); + await waitUntil(() => dialog(el).open); + const sandbox = dialog(el).querySelector("iframe")?.getAttribute("sandbox"); + + expect(sandbox).to.contain("allow-scripts"); + expect(sandbox).not.to.contain("allow-same-origin"); + expect(dialog(el).textContent).to.contain("Schliessen"); + dialog(el).close(); + }); + + it("stops listening for consent once removed", async () => { + const el = await fixture( + html`` + ); + const update = sinon.spy(el, "requestUpdate"); + el.remove(); + + saveConsent(COOKIES, { embeds: true }); + + expect(update.callCount).to.equal(0); + }); +}); diff --git a/packages/ecc-utils-design/src/components/cookie-consent/consent.ts b/packages/ecc-utils-design/src/components/cookie-consent/consent.ts new file mode 100644 index 00000000..9291b0e3 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/consent.ts @@ -0,0 +1,440 @@ +/** + * Cookie consent: configuration, storage and checks, shared by + * `ecc-utils-design-cookie-consent`, `ecc-utils-design-compliance-footer` + * and any other code, e.g. code that loads analytics only with consent. + * Framework independent: everything goes through `localStorage` and + * events on `window`. + */ + +/** A category of cookies (and similar storage) that users consent to. */ +export interface CookieCategory { + /** Stable identifier, e.g. "analytics"; changing it asks users again. */ + id: string; + /** Name shown to users, e.g. "Analytics". */ + label: string; + /** What the category is used for, shown in the preferences. */ + description?: string; + /** Strictly necessary: always on, can't be switched off. */ + required?: boolean; +} + +/** Texts of the banner and the preferences. */ +export interface CookieConsentTexts { + bannerTitle: string; + bannerText: string; + policyLink: string; + acceptAll: string; + rejectAll: string; + customize: string; + preferencesTitle: string; + preferencesText: string; + save: string; + close: string; + alwaysOn: string; + /** Shown instead of an embed without consent; `{category}` is replaced. */ + embedConsent: string; +} + +/** Configuration of the cookie consent. */ +export interface CookieConsentConfig { + /** + * Version of the cookie policy; changing it asks users again, as adding, + * removing or renaming categories does. + */ + version?: string; + /** `localStorage` key of the stored consent. */ + storageKey?: string; + /** + * Categories; a required "necessary" category is added if none is + * required. Without optional categories, the banner is shown only if + * `notice` is set, as nothing needs consent. + */ + categories?: CookieCategory[]; + /** Show the banner even if there are no optional categories. */ + notice?: boolean; + /** Texts; each has an English default. */ + texts?: Partial; + /** Link to the cookie policy, shown in the banner and preferences. */ + policyUrl?: string; +} + +/** Stored consent. */ +export interface CookieConsent { + /** Consent per category ID. */ + categories: Record; + /** Identifies the configuration that the consent was given for. */ + signature: string; + /** When the consent was given (ISO 8601). */ + updatedAt: string; +} + +/** What a footer item does when it is selected. */ +export type FooterItemAction = + /** Follow a link (`http(s)` or relative). */ + | { type: "link"; href: string; newTab?: boolean } + /** Open the mail client. */ + | { type: "mailto"; email: string; subject?: string } + /** Open the cookie preferences. */ + | { type: "preferences" } + /** Show a page from Markdown (headings, paragraphs, lists, links, bold). */ + | { type: "page"; markdown: string; title?: string } + /** + * Show an embedded document (`https` URL) in a frame, e.g. a policy + * hosted by Iubenda, Termly or GetTerms. With `consent`, the frame loads + * only once users consented to that category. + */ + | { type: "embed"; src: string; title?: string; consent?: string }; + +/** An item of the compliance footer. */ +export interface FooterItem { + /** Stable identifier, e.g. "privacy-policy". */ + id: string; + /** Text of the item. */ + label: string; + /** What the item does; items without an action are not shown. */ + action?: FooterItemAction; +} + +/** An item in the configuration: overrides the default with its ID. */ +export interface FooterItemConfig extends Partial { + id: string; + /** Removes the (default) item. */ + hidden?: boolean; +} + +/** Texts of the compliance footer. */ +export interface ComplianceFooterTexts { + /** Button that closes a page or embed. */ + close: string; +} + +/** Configuration of the compliance footer. */ +export interface ComplianceFooterConfig { + /** + * Items: entries with the ID of a default item change or hide it, others + * are added after the defaults, in their order. + */ + items?: FooterItemConfig[]; + /** Use only the configured items, not the defaults. */ + replaceDefaults?: boolean; + /** Accessible name of the footer's navigation, default "Legal". */ + label?: string; + /** Texts of the footer's dialog. */ + texts?: Partial; +} + +/** Shared configuration of the compliance components. */ +export interface ComplianceConfig { + cookies?: CookieConsentConfig; + footer?: ComplianceFooterConfig; +} + +/** Name of the `window` event fired when the consent was saved. */ +export const CONSENT_CHANGED_EVENT = "ecc-cookie-consent-changed"; + +/** Name of the `window` event that asks to open the cookie preferences. */ +export const PREFERENCES_REQUESTED_EVENT = "ecc-cookie-preferences-requested"; + +/** Default `localStorage` key. */ +export const DEFAULT_STORAGE_KEY = "ecc-cookie-consent"; + +/** The default required category. */ +export const NECESSARY_CATEGORY: CookieCategory = { + id: "necessary", + label: "Necessary", + description: + "Needed for the site to work, e.g. to keep you logged in and to remember these choices.", + required: true, +}; + +/** Default texts. */ +export const DEFAULT_COOKIE_TEXTS: CookieConsentTexts = { + bannerTitle: "Cookies", + bannerText: + "We use cookies and similar technologies. Some are necessary for this site to work; others are used only with your consent.", + policyLink: "Cookie Policy", + acceptAll: "Accept all", + rejectAll: "Necessary only", + customize: "Customize", + preferencesTitle: "Cookie preferences", + preferencesText: + "Choose which cookies we may use. You can change your choice at any time.", + save: "Save choices", + close: "Close", + alwaysOn: "Always on", + embedConsent: + "This content is shown only with your consent to {category} cookies.", +}; + +/** Default footer items; those without an action are shown once configured. */ +export const DEFAULT_FOOTER_ITEMS: readonly FooterItem[] = Object.freeze([ + { id: "privacy-policy", label: "Privacy Policy" }, + { id: "terms", label: "Terms & Conditions" }, + { id: "cookie-policy", label: "Cookie Policy" }, + { + id: "cookie-preferences", + label: "Cookie Preferences", + action: { type: "preferences" }, + }, + { id: "imprint", label: "Imprint" }, + { id: "accessibility", label: "Accessibility Statement" }, + { id: "contact", label: "Contact Us" }, + { id: "privacy-choices", label: "Your Privacy Choices" }, +]); + +/** + * The categories of a configuration, with a required one first. Entries + * with the same ID are merged (required if any of them is); entries that + * aren't usable (no ID, `__proto__`, a non-array setting) are skipped. + * @param config Cookie consent configuration + * @returns The categories + */ +export function cookieCategories( + config?: CookieConsentConfig +): CookieCategory[] { + const listed = config?.categories; + const configured = Array.isArray(listed) ? listed : []; + const categories = configured.filter( + (category) => + category && + typeof category.id === "string" && + category.id && + category.id !== "__proto__" + ); + const all = categories.some((category) => category.required) + ? categories + : [NECESSARY_CATEGORY, ...categories]; + const byId = new Map(); + all.forEach((category) => { + const known = byId.get(category.id); + byId.set( + category.id, + known + ? { + ...known, + ...category, + required: !!known.required || !!category.required, + } + : category + ); + }); + return Array.from(byId.values()); +} + +/** + * Identifies a configuration for stored consent: its version and its + * categories (IDs and whether they are required), so that consent is asked + * again when they change. + * @param config Cookie consent configuration + * @returns The signature + */ +export function consentSignature(config?: CookieConsentConfig): string { + const categories = cookieCategories(config) + .map((category): [string, boolean] => [category.id, !!category.required]) + .sort(([a], [b]) => (a < b ? -1 : Number(a > b))); + return JSON.stringify([config?.version ?? "", categories]); +} + +/** + * The `localStorage` key of a configuration. + * @param config Cookie consent configuration + * @returns The key + */ +export const consentStorageKey = (config?: CookieConsentConfig): string => + config?.storageKey || DEFAULT_STORAGE_KEY; + +// consent of this page that couldn't be stored, e.g. if storage is blocked +// or full; it takes precedence over what `localStorage` still holds +const memory = new Map(); + +function readItem(key: string): string | null { + if (memory.has(key)) return memory.get(key) ?? null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function writeItem(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + memory.delete(key); + } catch { + memory.set(key, value); + try { + // don't leave an older choice behind for other pages + window.localStorage.removeItem(key); + } catch { + // storage can't be used at all + } + } +} + +/** + * The stored consent, if it was given for this configuration. + * @param config Cookie consent configuration + * @returns The consent, or `null` if users must be asked (again) + */ +export function readConsent( + config?: CookieConsentConfig +): CookieConsent | null { + const raw = readItem(consentStorageKey(config)); + if (!raw) return null; + let stored: unknown; + try { + stored = JSON.parse(raw); + } catch { + return null; + } + if (!stored || typeof stored !== "object") return null; + const consent = stored as Partial; + if (consent.signature !== consentSignature(config)) return null; + if (!consent.categories || typeof consent.categories !== "object") { + return null; + } + const categories: Record = {}; + cookieCategories(config).forEach((category) => { + categories[category.id] = + !!category.required || consent.categories?.[category.id] === true; + }); + return { + categories, + signature: consent.signature, + updatedAt: String(consent.updatedAt ?? ""), + }; +} + +/** + * Whether users consented to a category. Required categories always are; + * unknown ones and those without a stored consent never are. + * @param category Category ID, e.g. "analytics" + * @param config Cookie consent configuration + * @returns Whether code may use the category + */ +export function hasConsent( + category: string, + config?: CookieConsentConfig +): boolean { + const known = cookieCategories(config).find((c) => c.id === category); + if (!known) return false; + if (known.required) return true; + return readConsent(config)?.categories[category] === true; +} + +/** Detail of the `window` event `ecc-cookie-consent-changed`. */ +export interface ConsentChangedDetail { + consent: CookieConsent; + /** Storage key of the configuration that the consent was saved for. */ + storageKey: string; +} + +// notifications in save order, also if a listener saves consent itself +const pending: ConsentChangedDetail[] = []; +let notifying = false; + +function notify(detail: ConsentChangedDetail): void { + pending.push(detail); + if (notifying) return; + notifying = true; + try { + while (pending.length) { + const next = pending.shift() as ConsentChangedDetail; + window.dispatchEvent( + new CustomEvent(CONSENT_CHANGED_EVENT, { detail: next }) + ); + } + } finally { + notifying = false; + } +} + +/** + * Stores the consent and fires `ecc-cookie-consent-changed` on `window`. + * @param config Cookie consent configuration + * @param choices Consent per category ID; required ones are always on + * @returns The stored consent + */ +export function saveConsent( + config: CookieConsentConfig | undefined, + choices: Record +): CookieConsent { + const categories: Record = {}; + cookieCategories(config).forEach((category) => { + categories[category.id] = + !!category.required || choices[category.id] === true; + }); + const consent: CookieConsent = { + categories, + signature: consentSignature(config), + updatedAt: new Date().toISOString(), + }; + const storageKey = consentStorageKey(config); + writeItem(storageKey, JSON.stringify(consent)); + notify({ consent, storageKey }); + return consent; +} + +/** + * Calls `listener` whenever consent for the storage key of `config` is + * saved, in this page or in another tab or window of the same origin. + * @param listener Called with the new consent; `null` if the consent of + * another tab was removed or doesn't fit `config` + * @param config Cookie consent configuration + * @returns A function that removes the listener + */ +export function onConsentChange( + listener: (consent: CookieConsent | null) => void, + config?: CookieConsentConfig +): () => void { + const handler = (event: Event) => { + const { detail } = event as CustomEvent>; + if (!detail?.consent) return; + if (detail.storageKey !== consentStorageKey(config)) return; + listener(detail.consent); + }; + const storageHandler = (event: StorageEvent) => { + // `null`: another tab cleared the storage + const key = consentStorageKey(config); + if (event.key !== null && event.key !== key) return; + // the other tab's write is newer than consent kept in memory here + memory.delete(key); + listener(readConsent(config)); + }; + window.addEventListener(CONSENT_CHANGED_EVENT, handler); + window.addEventListener("storage", storageHandler); + return () => { + window.removeEventListener(CONSENT_CHANGED_EVENT, handler); + window.removeEventListener("storage", storageHandler); + }; +} + +/** Asks the page's cookie consent component to open the preferences. */ +export function openCookiePreferences(): void { + window.dispatchEvent(new CustomEvent(PREFERENCES_REQUESTED_EVENT)); +} + +/** + * The footer items of a configuration: the defaults, changed, hidden or + * extended by the configured items, without those that have no valid + * action. + * @param config Footer configuration + * @returns The items to show + */ +export function footerItems(config?: ComplianceFooterConfig): FooterItem[] { + const listed = config?.items; + const configured = (Array.isArray(listed) ? listed : []).filter( + (item) => item && typeof item.id === "string" && item.id + ); + const byId = new Map(configured.map((item) => [item.id, item])); + const defaults = config?.replaceDefaults ? [] : DEFAULT_FOOTER_ITEMS; + const defaultIds = new Set(defaults.map((item) => item.id)); + const merged: (FooterItemConfig | FooterItem)[] = [ + ...defaults.map((item) => ({ ...item, ...byId.get(item.id) })), + ...configured.filter((item) => !defaultIds.has(item.id)), + ]; + return merged + .filter((item) => !(item as FooterItemConfig).hidden) + .filter((item): item is FooterItem => !!item.label && !!item.action) + .map(({ id, label, action }) => ({ id, label, action })); +} diff --git a/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts b/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts new file mode 100644 index 00000000..4e7024d7 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts @@ -0,0 +1,318 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { + ComplianceConfig, + CookieCategory, + CookieConsent, + DEFAULT_COOKIE_TEXTS, + PREFERENCES_REQUESTED_EVENT, + consentSignature, + consentStorageKey, + cookieCategories, + onConsentChange, + readConsent, + saveConsent, +} from "./consent.js"; +import { safeHref } from "./urls.js"; +import "../button/index.js"; + +/** + * @summary Cookie consent banner and preferences, configured by the shared + * compliance configuration. + * @since 2.0.0 + * + * @property {ComplianceConfig} config - Shared compliance configuration; the component uses `config.cookies`. A new object with the same categories, version, storage key and `notice` keeps the user's unsaved choices. + * + * @fires ecc-cookie-consent-changed - Fired when the user saved their choices (detail: `{ consent }`). The event doesn't bubble; the same event fires on `window` for any code on the page. + * + * @csspart banner - The banner. + * @csspart dialog - The preferences dialog. + */ +export class EccUtilsDesignCookieConsent extends LitElement { + static styles = [ + css` + ${TailwindStyles} + `, + css` + ${GlobalStyles} + `, + css` + :host { + display: contents; + } + dialog::backdrop { + background: rgb(0 0 0 / 0.5); + } + `, + ]; + + @property({ attribute: false }) config: ComplianceConfig = {}; + + /** Whether the banner is shown. */ + @state() private bannerOpen = false; + + /** Choices in the preferences, per category ID. */ + @state() private choices: Record = {}; + + @query("dialog") private dialog?: HTMLDialogElement; + + /** Categories that users switched in the open preferences. */ + private touched = new Set(); + + /** What the banner and the choices were last derived from. */ + private derivedFrom?: string; + + private stopListening?: () => void; + + private readonly onPreferencesRequested = () => this.openPreferences(); + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener( + PREFERENCES_REQUESTED_EVENT, + this.onPreferencesRequested + ); + this.listen(); + // consent may have changed while the element was detached + if (this.hasUpdated) this.refresh(); + } + + disconnectedCallback(): void { + window.removeEventListener( + PREFERENCES_REQUESTED_EVENT, + this.onPreferencesRequested + ); + this.stopListening?.(); + this.stopListening = undefined; + super.disconnectedCallback(); + } + + protected willUpdate(changed: Map): void { + if (!changed.has("config")) return; + const cookies = this.config?.cookies; + const derivedFrom = JSON.stringify([ + consentSignature(cookies), + consentStorageKey(cookies), + !!cookies?.notice, + ]); + // a new but equal object (e.g. from a React render) keeps the choices + if (derivedFrom === this.derivedFrom) return; + this.derivedFrom = derivedFrom; + this.refresh(); + if (this.isConnected) this.listen(); + } + + /** Follows consent saved in this page or in other tabs. */ + private listen(): void { + this.stopListening?.(); + this.stopListening = onConsentChange((consent) => { + if (!this.dialog?.open) { + this.refresh(); + return; + } + // keep what users switched here, take the rest from the new consent + const choices = this.currentChoices(consent); + this.touched.forEach((id) => { + choices[id] = !!this.choices[id]; + }); + this.choices = choices; + }, this.config?.cookies); + } + + /** Shows the banner and sets the choices from the stored consent. */ + private refresh(): void { + const consent = readConsent(this.config?.cookies); + const optional = this.categories.some((category) => !category.required); + this.bannerOpen = !consent && (optional || !!this.config?.cookies?.notice); + this.choices = this.currentChoices(consent); + } + + /** Opens the preferences, e.g. from a "Cookie Preferences" link. */ + async openPreferences(): Promise { + if (!this.dialog?.open) { + this.touched.clear(); + this.choices = this.currentChoices(readConsent(this.config?.cookies)); + } + await this.updateComplete; + if (this.isConnected && this.dialog && !this.dialog.open) { + this.dialog.showModal(); + } + } + + private get categories(): CookieCategory[] { + return cookieCategories(this.config?.cookies); + } + + private get texts() { + return { ...DEFAULT_COOKIE_TEXTS, ...this.config?.cookies?.texts }; + } + + private currentChoices(consent: CookieConsent | null) { + const choices: Record = {}; + this.categories.forEach((category) => { + choices[category.id] = + !!category.required || consent?.categories[category.id] === true; + }); + return choices; + } + + private save(choices: Record): void { + const consent = saveConsent(this.config?.cookies, choices); + this.choices = this.currentChoices(consent); + this.bannerOpen = false; + if (this.dialog?.open) this.dialog.close(); + this.dispatchEvent( + // not bubbling: `saveConsent()` fires the same event on `window`; + // a literal (= CONSENT_CHANGED_EVENT), which the React wrapper needs + new CustomEvent("ecc-cookie-consent-changed", { detail: { consent } }) + ); + } + + private all(on: boolean): Record { + const choices: Record = {}; + this.categories.forEach((category) => { + choices[category.id] = on || !!category.required; + }); + return choices; + } + + private toggle(id: string, event: Event): void { + const { checked } = event.target as HTMLInputElement; + this.touched.add(id); + this.choices = { ...this.choices, [id]: checked }; + } + + private renderPolicyLink() { + const href = safeHref(this.config?.cookies?.policyUrl); + if (!href) return nothing; + return html`${this.texts.policyLink}`; + } + + private renderBanner() { + if (!this.bannerOpen) return nothing; + const { texts } = this; + return html` +
    +
    +
    + +

    ${texts.bannerText} ${this.renderPolicyLink()}

    +
    +
    + this.openPreferences()} + >${texts.customize} + this.save(this.all(false))} + >${texts.rejectAll} + this.save(this.all(true))} + >${texts.acceptAll} +
    +
    +
    + `; + } + + private renderCategory(category: CookieCategory) { + const inputId = `ecc-cookie-category-${category.id}`; + return html` +
  • + this.toggle(category.id, event)} + /> + +
  • + `; + } + + private renderDialog() { + const { texts } = this; + return html` + this.refresh()} + class="m-auto w-[min(32rem,calc(100%-2rem))] rounded-lg border border-border bg-background p-6 text-foreground shadow-lg" + > + +

    + ${texts.preferencesText} ${this.renderPolicyLink()} +

    +
      + ${this.categories.map((category) => this.renderCategory(category))} +
    +
    + this.dialog?.close()} + >${texts.close} + this.save(this.all(false))} + >${texts.rejectAll} + this.save(this.all(true))} + >${texts.acceptAll} + this.save(this.choices)} + >${texts.save} +
    +
    + `; + } + + render() { + return html`${this.renderBanner()}${this.renderDialog()}`; + } +} + +export default EccUtilsDesignCookieConsent; diff --git a/packages/ecc-utils-design/src/components/cookie-consent/index.ts b/packages/ecc-utils-design/src/components/cookie-consent/index.ts new file mode 100644 index 00000000..335062bd --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/index.ts @@ -0,0 +1,24 @@ +import EccUtilsDesignCookieConsent from "./cookie-consent.js"; + +export * from "./cookie-consent.js"; +export * from "./consent.js"; +export * from "./urls.js"; +export default EccUtilsDesignCookieConsent; + +// Define the custom element +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-utils-design-cookie-consent") +) { + window.customElements.define( + "ecc-utils-design-cookie-consent", + EccUtilsDesignCookieConsent + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ecc-utils-design-cookie-consent": EccUtilsDesignCookieConsent; + } +} diff --git a/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts b/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts new file mode 100644 index 00000000..7037ce0e --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts @@ -0,0 +1,576 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type EccUtilsDesignCookieConsent from "../cookie-consent.js"; +import { + CONSENT_CHANGED_EVENT, + ComplianceConfig, + CookieConsentConfig, + DEFAULT_STORAGE_KEY, + consentSignature, + cookieCategories, + hasConsent, + onConsentChange, + openCookiePreferences, + readConsent, + saveConsent, +} from "../consent.js"; + +const COOKIES: CookieConsentConfig = { + version: "1", + categories: [ + { id: "necessary", label: "Necessary", required: true }, + { id: "analytics", label: "Analytics", description: "Usage statistics" }, + ], +}; +const CONFIG: ComplianceConfig = { cookies: COOKIES }; + +const banner = (el: EccUtilsDesignCookieConsent) => + el.shadowRoot?.querySelector("[part=banner]") ?? null; + +const dialog = (el: EccUtilsDesignCookieConsent) => + el.shadowRoot?.querySelector("dialog") as HTMLDialogElement; + +const button = (root: ParentNode, action: string) => + root.querySelector(`[data-action=${action}]`) as HTMLElement; + +describe("cookie consent store", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("adds a required category if none is", () => { + const categories = cookieCategories({ + categories: [{ id: "analytics", label: "Analytics" }], + }); + + expect(categories.map((c) => c.id)).to.deep.equal([ + "necessary", + "analytics", + ]); + expect(categories[0].required).to.equal(true); + }); + + it("has no consent before users chose", () => { + expect(readConsent(COOKIES)).to.equal(null); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(hasConsent("necessary", COOKIES)).to.equal(true); + }); + + it("stores consent and tells listeners", () => { + const listener = sinon.spy(); + const stop = onConsentChange(listener); + + saveConsent(COOKIES, { analytics: true, unknown: true, necessary: false }); + stop(); + saveConsent(COOKIES, { analytics: false }); + + expect(listener.callCount).to.equal(1); + expect(listener.firstCall.args[0].categories).to.deep.equal({ + necessary: true, + analytics: true, + }); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(hasConsent("unknown", COOKIES)).to.equal(false); + }); + + it("asks again when the categories or the version change", () => { + saveConsent(COOKIES, { analytics: true }); + + expect(readConsent(COOKIES)).not.to.equal(null); + expect(readConsent({ ...COOKIES, version: "2" })).to.equal(null); + expect( + readConsent({ + ...COOKIES, + categories: [ + ...(COOKIES.categories ?? []), + { id: "ads", label: "Ads" }, + ], + }) + ).to.equal(null); + // texts don't count + expect( + readConsent({ ...COOKIES, texts: { bannerTitle: "Cookies!" } }) + ).not.to.equal(null); + }); + + it("ignores stored values it can't use", () => { + window.localStorage.setItem(DEFAULT_STORAGE_KEY, "not JSON"); + expect(readConsent(COOKIES)).to.equal(null); + + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ signature: consentSignature(COOKIES), categories: 1 }) + ); + expect(readConsent(COOKIES)).to.equal(null); + + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ + signature: consentSignature(COOKIES), + categories: { analytics: "yes" }, + }) + ); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + }); + + it("uses the configured storage key", () => { + saveConsent({ ...COOKIES, storageKey: "my-consent" }, { analytics: true }); + + expect(window.localStorage.getItem("my-consent")).to.contain("analytics"); + expect(window.localStorage.getItem(DEFAULT_STORAGE_KEY)).to.equal(null); + }); + + it("keeps consent for the page if storage fails", () => { + sinon.stub(Storage.prototype, "setItem").throws(new Error("blocked")); + sinon.stub(Storage.prototype, "getItem").throws(new Error("blocked")); + try { + saveConsent({ ...COOKIES, storageKey: "blocked" }, { analytics: true }); + + expect( + hasConsent("analytics", { ...COOKIES, storageKey: "blocked" }) + ).to.equal(true); + } finally { + sinon.restore(); + } + }); +}); + +describe("cookie consent store, edge cases", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("asks again when a required category becomes optional", () => { + const forced: CookieConsentConfig = { + categories: [ + { id: "necessary", label: "Necessary", required: true }, + { id: "analytics", label: "Analytics", required: true }, + ], + }; + saveConsent(forced, {}); + const optional: CookieConsentConfig = { + categories: [ + { id: "necessary", label: "Necessary", required: true }, + { id: "analytics", label: "Analytics" }, + ], + }; + + expect(readConsent(optional)).to.equal(null); + expect(hasConsent("analytics", optional)).to.equal(false); + }); + + it("revokes consent if storage can be read but not written", () => { + saveConsent(COOKIES, { analytics: true }); + sinon.stub(Storage.prototype, "setItem").throws(new Error("full")); + + saveConsent(COOKIES, { analytics: false }); + + expect(hasConsent("analytics", COOKIES)).to.equal(false); + sinon.restore(); + // the older choice doesn't stay behind for other pages + expect(window.localStorage.getItem(DEFAULT_STORAGE_KEY)).to.equal(null); + + // once storage works again, it is used again + saveConsent(COOKIES, { analytics: false }); + expect(window.localStorage.getItem(DEFAULT_STORAGE_KEY)).to.contain( + "analytics" + ); + }); + + it("prefers another tab's consent to one kept in memory", () => { + sinon.stub(Storage.prototype, "setItem").throws(new Error("full")); + saveConsent(COOKIES, { analytics: true }); + sinon.restore(); + const listener = sinon.spy(); + const stop = onConsentChange(listener, COOKIES); + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ + categories: { necessary: true, analytics: false }, + signature: consentSignature(COOKIES), + updatedAt: "2026-10-29T00:00:00Z", + }) + ); + + window.dispatchEvent( + new StorageEvent("storage", { key: DEFAULT_STORAGE_KEY }) + ); + // another tab cleared the storage + window.localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + stop(); + + expect(listener.firstCall.args[0].categories.analytics).to.equal(false); + expect(listener.secondCall.args[0]).to.equal(null); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + }); + + it("tells listeners only about their own configuration", () => { + const listener = sinon.spy(); + const stop = onConsentChange(listener, COOKIES); + + saveConsent({ ...COOKIES, storageKey: "other-app" }, { analytics: true }); + saveConsent(COOKIES, { analytics: false }); + stop(); + + expect(listener.callCount).to.equal(1); + expect(listener.firstCall.args[0].categories.analytics).to.equal(false); + }); + + it("tells listeners in save order if a listener saves", () => { + const seen: boolean[] = []; + const stopFirst = onConsentChange((consent) => { + if (consent?.categories.analytics) { + saveConsent(COOKIES, { analytics: false }); + } + }, COOKIES); + const stopSecond = onConsentChange((consent) => { + seen.push(!!consent?.categories.analytics); + }, COOKIES); + + saveConsent(COOKIES, { analytics: true }); + stopFirst(); + stopSecond(); + + expect(seen).to.deep.equal([true, false]); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + }); + + it("merges duplicate categories and skips unusable ones", () => { + const config = { + categories: [ + { id: "necessary", label: "Needed" }, + { id: "analytics", label: "Analytics" }, + { id: "analytics", label: "Statistics", required: true }, + { id: "__proto__", label: "Proto" }, + ], + } as CookieConsentConfig; + + const categories = cookieCategories(config); + + expect(categories.map((c) => [c.id, c.label, !!c.required])).to.deep.equal([ + ["necessary", "Needed", false], + ["analytics", "Statistics", true], + ]); + }); + + it("adds the necessary category for a configured optional one", () => { + const categories = cookieCategories({ + categories: [{ id: "necessary", label: "Needed" }], + }); + + expect(categories).to.have.length(1); + expect(categories[0].required).to.equal(true); + }); + + it("ignores settings that aren't lists", () => { + const config = { + categories: "analytics", + } as unknown as CookieConsentConfig; + + expect(cookieCategories(config).map((c) => c.id)).to.deep.equal([ + "necessary", + ]); + }); + + it("tells listeners about consent saved in other tabs", () => { + const listener = sinon.spy(); + const stop = onConsentChange(listener, COOKIES); + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ + categories: { necessary: true, analytics: true }, + signature: consentSignature(COOKIES), + updatedAt: "2026-10-29T00:00:00Z", + }) + ); + + window.dispatchEvent(new StorageEvent("storage", { key: "other" })); + window.dispatchEvent( + new StorageEvent("storage", { key: DEFAULT_STORAGE_KEY }) + ); + stop(); + window.dispatchEvent( + new StorageEvent("storage", { key: DEFAULT_STORAGE_KEY }) + ); + + expect(listener.callCount).to.equal(1); + expect(listener.firstCall.args[0].categories.analytics).to.equal(true); + }); +}); + +describe("ecc-utils-design-cookie-consent", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("shows the banner until users chose", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)).not.to.equal(null); + + const changed = oneEvent(el, "ecc-cookie-consent-changed"); + button(banner(el) as Element, "accept").click(); + const { detail } = await changed; + await el.updateComplete; + + expect(detail.consent.categories.analytics).to.equal(true); + expect(banner(el)).to.equal(null); + expect(hasConsent("analytics", COOKIES)).to.equal(true); + }); + + it("lets users reject optional cookies as easily", async () => { + const el = await fixture( + html`` + ); + const onWindow = sinon.spy(); + window.addEventListener(CONSENT_CHANGED_EVENT, onWindow); + + button(banner(el) as Element, "reject").click(); + await el.updateComplete; + window.removeEventListener(CONSENT_CHANGED_EVENT, onWindow); + + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(readConsent(COOKIES)).not.to.equal(null); + expect(onWindow.callCount).to.equal(1); + expect(banner(el)).to.equal(null); + }); + + it("doesn't show the banner after a choice", async () => { + saveConsent(COOKIES, { analytics: false }); + const el = await fixture( + html`` + ); + + expect(banner(el)).to.equal(null); + }); + + it("needs no banner without optional categories", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)).to.equal(null); + + el.config = { cookies: { notice: true } }; + await el.updateComplete; + expect(banner(el)).not.to.equal(null); + }); + + it("saves the choices of the preferences", async () => { + const el = await fixture( + html`` + ); + + openCookiePreferences(); + await waitUntil(() => dialog(el).open); + const boxes = dialog(el).querySelectorAll( + "input[type=checkbox]" + ); + expect(boxes).to.have.length(2); + expect(boxes[0].disabled).to.equal(true); + expect(boxes[0].checked).to.equal(true); + boxes[1].click(); + await el.updateComplete; + button(dialog(el), "save").click(); + await el.updateComplete; + + expect(dialog(el).open).to.equal(false); + expect(hasConsent("analytics", COOKIES)).to.equal(true); + expect(banner(el)).to.equal(null); + + // reopened, it shows the stored choice + await el.openPreferences(); + const reopened = dialog(el).querySelectorAll( + "input[type=checkbox]" + ); + expect(reopened[1].checked).to.equal(true); + dialog(el).close(); + }); + + it("links only safe policy URLs", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)?.querySelector("a")).to.equal(null); + + el.config = { cookies: { ...COOKIES, policyUrl: "/cookies" } }; + await el.updateComplete; + expect(banner(el)?.querySelector("a")?.getAttribute("href")).to.equal( + "/cookies" + ); + }); + + it("uses configured texts", async () => { + const el = await fixture( + html`` + ); + + expect(button(banner(el) as Element, "accept").textContent).to.contain( + "Alle" + ); + }); + + it("keeps unsaved choices when an equal config is passed", async () => { + const el = await fixture( + html`` + ); + await el.openPreferences(); + const box = () => + dialog(el).querySelectorAll("input[type=checkbox]")[1]; + box().click(); + await el.updateComplete; + + // e.g. a React render with an inline object + el.config = { cookies: { ...COOKIES } }; + await el.updateComplete; + + expect(dialog(el).open).to.equal(true); + expect(box().checked).to.equal(true); + expect(banner(el)).not.to.equal(null); + dialog(el).close(); + }); + + it("follows consent saved in another tab", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)).not.to.equal(null); + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ + categories: { necessary: true, analytics: false }, + signature: consentSignature(COOKIES), + updatedAt: "2026-10-29T00:00:00Z", + }) + ); + + window.dispatchEvent( + new StorageEvent("storage", { key: DEFAULT_STORAGE_KEY }) + ); + await el.updateComplete; + + expect(banner(el)).to.equal(null); + }); + + it("follows other tabs once the preferences are closed", async () => { + const el = await fixture( + html`` + ); + await el.openPreferences(); + saveConsent(COOKIES, { analytics: true }); + await el.updateComplete; + expect(banner(el)).not.to.equal(null); + + dialog(el).close(); + await waitUntil(() => banner(el) === null); + }); + + it("follows consent changed while it was detached", async () => { + const el = await fixture( + html`` + ); + const parent = el.parentElement as HTMLElement; + el.remove(); + saveConsent(COOKIES, { analytics: false }); + + parent.appendChild(el); + await el.updateComplete; + + expect(banner(el)).to.equal(null); + }); + + it("merges another tab's consent into open preferences", async () => { + const cookies: CookieConsentConfig = { + ...COOKIES, + categories: [ + ...(COOKIES.categories ?? []), + { id: "marketing", label: "Marketing" }, + ], + }; + const el = await fixture( + html`` + ); + await el.openPreferences(); + const boxes = () => + dialog(el).querySelectorAll("input[type=checkbox]"); + boxes()[1].click(); // analytics on + await el.updateComplete; + + saveConsent(cookies, { marketing: true }); + await el.updateComplete; + button(dialog(el), "save").click(); + await el.updateComplete; + + expect(hasConsent("analytics", cookies)).to.equal(true); + expect(hasConsent("marketing", cookies)).to.equal(true); + }); + + it("doesn't open the preferences once removed", async () => { + const el = await fixture( + html`` + ); + const opening = el.openPreferences(); + el.remove(); + + await opening; + expect(dialog(el).open).to.equal(false); + }); + + it("stops listening once removed", async () => { + const el = await fixture( + html`` + ); + const open = sinon.spy(el, "openPreferences"); + el.remove(); + + openCookiePreferences(); + saveConsent(COOKIES, { analytics: true }); + + expect(open.callCount).to.equal(0); + }); +}); diff --git a/packages/ecc-utils-design/src/components/cookie-consent/tests/urls.test.ts b/packages/ecc-utils-design/src/components/cookie-consent/tests/urls.test.ts new file mode 100644 index 00000000..60419408 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/tests/urls.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable import/no-extraneous-dependencies, no-script-url */ +import { expect } from "@open-wc/testing"; +import { + isExternal, + isSameOrigin, + mailtoHref, + safeEmbedSrc, + safeHref, +} from "../urls.js"; + +describe("URL checks", () => { + it("allows only safe links", () => { + expect(safeHref(" https://example.org/a ")).to.equal( + "https://example.org/a" + ); + expect(safeHref("/privacy")).to.equal("/privacy"); + expect(safeHref("mailto:dpo@example.org")).to.equal( + "mailto:dpo@example.org" + ); + expect(safeHref("javascript:alert(1)")).to.equal(undefined); + expect(safeHref("\u0001javascript:alert(1)")).to.equal(undefined); + expect(safeHref("data:text/html,x")).to.equal(undefined); + expect(safeHref("")).to.equal(undefined); + expect(safeHref(42)).to.equal(undefined); + }); + + it("embeds only https, and http on localhost", () => { + expect(safeEmbedSrc("https://policies.example.org/p")).to.equal( + "https://policies.example.org/p" + ); + expect(safeEmbedSrc("http://localhost:8080/p")).to.equal( + "http://localhost:8080/p" + ); + expect(safeEmbedSrc("http://example.org/p")).to.equal(undefined); + expect(safeEmbedSrc("javascript:alert(1)")).to.equal(undefined); + expect(safeEmbedSrc(undefined)).to.equal(undefined); + }); + + it("builds mailto links only for plain addresses", () => { + expect(mailtoHref("dpo@example.org", "Data & you")).to.equal( + "mailto:dpo@example.org?subject=Data%20%26%20you" + ); + expect(mailtoHref("first.last+tag@sub.example.org")).to.equal( + "mailto:first.last+tag@sub.example.org" + ); + expect(mailtoHref("a%0D%0ABcc:evil@example.org")).to.equal(undefined); + expect(mailtoHref("a@example.org?cc=evil@example.org")).to.equal(undefined); + expect(mailtoHref("a@example.org,evil@example.org")).to.equal(undefined); + expect(mailtoHref("not an address")).to.equal(undefined); + }); + + it("tells external and same-origin URLs apart", () => { + expect(isExternal("https://other.example.org/")).to.equal(true); + expect(isExternal("/privacy")).to.equal(false); + expect(isExternal("mailto:dpo@example.org")).to.equal(false); + expect(isSameOrigin("/legal/privacy")).to.equal(true); + expect(isSameOrigin("https://other.example.org/")).to.equal(false); + }); +}); diff --git a/packages/ecc-utils-design/src/components/cookie-consent/urls.ts b/packages/ecc-utils-design/src/components/cookie-consent/urls.ts new file mode 100644 index 00000000..36c8c7ff --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/urls.ts @@ -0,0 +1,98 @@ +/** + * Checks of configured URLs, so that configuration can't run scripts + * (e.g. `javascript:` links). + */ + +const SAFE_LINK_PROTOCOLS = ["http:", "https:", "mailto:"]; + +/** + * The URL if it is safe as a link: `http(s)`, `mailto` or relative. + * @param url URL from the configuration or a Markdown page + * @returns The URL, or `undefined` if it isn't safe + */ +export function safeHref(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed, window.location.href); + return SAFE_LINK_PROTOCOLS.includes(parsed.protocol) ? trimmed : undefined; + } catch { + return undefined; + } +} + +/** + * The URL if it may be embedded in a frame: absolute `https`, or relative + * on a page loaded over `https` or from localhost. + * @param url URL from the configuration + * @returns The URL, or `undefined` if it isn't allowed + */ +export function safeEmbedSrc(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed, window.location.href); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + return parsed.protocol === "https:" || + (parsed.protocol === "http:" && local) + ? parsed.href + : undefined; + } catch { + return undefined; + } +} + +// a plain address: no percent-encoding, header separators or extra recipients +const EMAIL = /^[A-Za-z0-9._+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$/; + +/** + * Whether a URL is absolute and points to another origin, e.g. to open it in + * a new tab. + * @param url A URL that passed `safeHref()` + * @returns Whether it is external + */ +export function isExternal(url: string): boolean { + try { + const parsed = new URL(url, window.location.href); + return ( + parsed.protocol !== "mailto:" && parsed.origin !== window.location.origin + ); + } catch { + return false; + } +} + +/** + * Whether a URL has the origin of the page. + * @param url A URL that passed `safeEmbedSrc()` + * @returns Whether it is same-origin + */ +export function isSameOrigin(url: string): boolean { + try { + return new URL(url, window.location.href).origin === window.location.origin; + } catch { + return false; + } +} + +/** + * A `mailto:` link for an address, or `undefined` if it isn't one. + * @param email E-mail address + * @param subject Optional subject + * @returns The link + */ +export function mailtoHref( + email: unknown, + subject?: unknown +): string | undefined { + if (typeof email !== "string" || !EMAIL.test(email)) { + return undefined; + } + const query = + typeof subject === "string" && subject + ? `?subject=${encodeURIComponent(subject)}` + : ""; + return `mailto:${email}${query}`; +} diff --git a/packages/ecc-utils-design/src/components/index.ts b/packages/ecc-utils-design/src/components/index.ts index c7d74c1c..380fd2d4 100644 --- a/packages/ecc-utils-design/src/components/index.ts +++ b/packages/ecc-utils-design/src/components/index.ts @@ -14,3 +14,6 @@ export { default as EccUtilsDesignCard } from "./card/index.js"; export { default as EccUtilsDesignSeparator } from "./separator/index.js"; export { default as EccUtilsDesignCode } from "./code/index.js"; export { default as EccUtilsDesignTextarea } from "./textarea/index.js"; +export { default as EccUtilsDesignCookieConsent } from "./cookie-consent/index.js"; +export { default as EccUtilsDesignComplianceFooter } from "./compliance-footer/index.js"; +export * from "./cookie-consent/consent.js"; diff --git a/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts b/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts new file mode 100644 index 00000000..e0e98b0b --- /dev/null +++ b/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts @@ -0,0 +1,12 @@ +import type { FooterItem } from "../components/cookie-consent/consent.js"; + +export type EccComplianceItemSelectedEvent = CustomEvent<{ + /** The selected item. */ + item: FooterItem; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-compliance-item-selected": EccComplianceItemSelectedEvent; + } +} diff --git a/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts b/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts new file mode 100644 index 00000000..f0e9db2f --- /dev/null +++ b/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts @@ -0,0 +1,12 @@ +import type { CookieConsent } from "../components/cookie-consent/consent.js"; + +export type EccCookieConsentChangedEvent = CustomEvent<{ + /** The saved consent. */ + consent: CookieConsent; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-cookie-consent-changed": EccCookieConsentChangedEvent; + } +} diff --git a/packages/ecc-utils-design/src/events/index.ts b/packages/ecc-utils-design/src/events/index.ts index 8ed63d3d..19c622a6 100644 --- a/packages/ecc-utils-design/src/events/index.ts +++ b/packages/ecc-utils-design/src/events/index.ts @@ -4,3 +4,5 @@ export type { EccCollapsibleToggledEvent } from "./ecc-collapsible-toggled.js"; export type { EccPaginationChangedEvent } from "./ecc-pagination-changed.js"; export type { EccButtonClickedEvent } from "./ecc-button-clicked.js"; export type { EccInputChangedEvent } from "./ecc-input-changed.js"; +export type { EccCookieConsentChangedEvent } from "./ecc-cookie-consent-changed.js"; +export type { EccComplianceItemSelectedEvent } from "./ecc-compliance-item-selected.js";