Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/documentation/pages/docs/design/components/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@
"multi-select": "Multi-Select",
"pagination": "Pagination",
"tabs": "Tabs",
"table": "Table"
}
"table": "Table",
"cookie-consent": "Cookie Consent",
"compliance-footer": "Compliance Footer"
}
Original file line number Diff line number Diff line change
@@ -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

<ComponentName
tagName='ecc-utils-design-compliance-footer'
reactTagName='EccUtilsDesignComplianceFooter'
version='2.0.0-alpha.50'
/>

## 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. |

<Callout type='warning'>
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.
</Callout>

## Usage

<Tabs items={['Preview', 'React', 'HTML']}>
<Tabs.Tab>
<ComplianceFooter
config={{
footer: {
items: [
{ id: 'privacy-policy', action: { type: 'page', markdown: '# Privacy Policy\n\nThis demo stores **no** personal data.\n\n- No analytics\n- No tracking' } },
{ id: 'imprint', action: { type: 'page', markdown: 'Operated by **Example Institute**, Example Street 1, Example City.' } },
{ id: 'contact', action: { type: 'mailto', email: 'support@example.org', subject: 'Question' } },
{ id: 'status', label: 'Service Status', action: { type: 'link', href: 'https://example.org/status', newTab: true } },
],
},
}}
/>
</Tabs.Tab>
<Tabs.Tab>
```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 <EccUtilsDesignComplianceFooter config={compliance} />;
}
```
</Tabs.Tab>
<Tabs.Tab>
```html showLineNumbers copy filename="footer.html"
<script type="module" src="https://esm.sh/@elixir-cloud/design@2.0.0-alpha.50/components/compliance-footer"></script>
<ecc-utils-design-compliance-footer></ecc-utils-design-compliance-footer>
<script type="module">
document.querySelector('ecc-utils-design-compliance-footer').config = {
footer: {
items: [{ id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }],
},
};
</script>
```
</Tabs.Tab>
</Tabs>

## 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 `<script>` tag that injects the policy) aren't supported, since configuration must not run code on the page. Most policy services also offer a page URL that can be embedded or linked.
179 changes: 179 additions & 0 deletions apps/documentation/pages/docs/design/components/cookie-consent.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Callout, Tabs } from 'nextra/components'
import ComponentName from '@/components/common/ComponentName'
import CookieConsent from '@elixir-cloud/design/react/cookie-consent';
import ComplianceFooter from '@elixir-cloud/design/react/compliance-footer';

# EccUtilsDesignCookieConsent Component

<ComponentName
tagName='ecc-utils-design-cookie-consent'
reactTagName='EccUtilsDesignCookieConsent'
version='2.0.0-alpha.50'
/>

## Overview

The `ecc-utils-design-cookie-consent` component asks users for their consent to cookies (and similar storage, such as `localStorage`), per category, and lets them change their choice at any time. It works together with the [compliance footer](/docs/design/components/compliance-footer), which links to the preferences, and both read the same configuration, so an app adds them from configuration alone.

- **Banner** on the first visit, with "Accept all", "Necessary only" (as prominent as accepting) and "Customize".
- **Preferences** dialog with one switch per category; strictly necessary categories are always on.
- **Stored** in the browser (`localStorage`, key `ecc-cookie-consent`), and asked again when the configured categories or the `version` change.
- **Checks** for other code: `hasConsent('analytics')`, `onConsentChange(...)` and the `ecc-cookie-consent-changed` event on `window`.

<Callout type='warning'>
The component provides the mechanics only. Operators decide which categories their deployment needs and are responsible for the texts, the policies they link to, and for loading optional scripts only with consent (see [Loading scripts with consent](#loading-scripts-with-consent)).
</Callout>

Without optional categories, nothing needs consent and the banner isn't shown (unless `notice` is set); the preferences still list the necessary category, e.g. for the footer's "Cookie Preferences" item.

## Preview

This page shows the banner until you choose (stored only for this demo); the footer's "Cookie Preferences" opens the preferences again.

<div className="mt-4 rounded-md border p-4">
<ComplianceFooter config={{}} />
<CookieConsent
config={{
cookies: {
storageKey: 'ecc-docs-cookie-consent-demo',
categories: [
{ id: 'necessary', label: 'Necessary', required: true, description: 'Remembers these choices.' },
{ id: 'analytics', label: 'Analytics', description: 'Not used on this page; for the demo only.' },
],
},
}}
/>
</div>

## Usage

Add the component once per page, e.g. in the app's layout, next to the compliance footer:

<Tabs items={['React', 'HTML']}>
<Tabs.Tab>
```tsx showLineNumbers copy filename="layout.tsx"
import EccUtilsDesignCookieConsent from '@elixir-cloud/design/react/cookie-consent';
import EccUtilsDesignComplianceFooter from '@elixir-cloud/design/react/compliance-footer';
import type { ComplianceConfig } from '@elixir-cloud/design/components/cookie-consent/index.js';

// e.g. from the deployment's runtime settings
const compliance: ComplianceConfig = {
cookies: {
version: '2026-10',
policyUrl: '/cookies',
categories: [
{ id: 'necessary', label: 'Necessary', required: true,
description: 'Login and these choices.' },
{ id: 'analytics', label: 'Analytics',
description: 'Anonymous usage statistics.' },
],
},
footer: {
items: [
{ id: 'privacy-policy', action: { type: 'link', href: '/privacy' } },
{ id: 'contact', action: { type: 'mailto', email: 'support@example.org' } },
],
},
};

export default function Layout({ children }) {
return (
<>
{children}
<footer>
<EccUtilsDesignComplianceFooter config={compliance} />
</footer>
<EccUtilsDesignCookieConsent
config={compliance}
onEccCookieConsentChanged={(event) =>
console.log(event.detail.consent.categories)
}
/>
</>
);
}
```
</Tabs.Tab>
<Tabs.Tab>
```html showLineNumbers copy filename="index.html"
<script type="module" src="https://esm.sh/@elixir-cloud/design@2.0.0-alpha.50/components/cookie-consent"></script>
Comment thread
uniqueg marked this conversation as resolved.
Resolved
<ecc-utils-design-cookie-consent></ecc-utils-design-cookie-consent>
<script type="module">
document.querySelector('ecc-utils-design-cookie-consent').config = {
cookies: {
categories: [{ id: 'analytics', label: 'Analytics' }],
},
};
</script>
```
</Tabs.Tab>
</Tabs>

## 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<CookieConsentTexts>` | 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.
Loading
Loading