@madgex/design-system 14.1.0 → 14.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/icons.json +1 -1
- package/dist/css/index.css +1 -1
- package/dist/js/components/mds-consent-gate-standalone.js +5 -0
- package/dist/js/components/mds-image-cropper-standalone.js +1 -1
- package/dist/js/components/mds-timeout-dialog-standalone.js +1 -1
- package/dist/js/consent-gate-DVi5q-ir.js +1 -0
- package/dist/js/index.js +1 -1
- package/package.json +1 -1
- package/src/components/_macro-index.njk +1 -0
- package/src/components/consent-gate/README.md +168 -0
- package/src/components/consent-gate/_macro.njk +30 -0
- package/src/components/consent-gate/_template.njk +14 -0
- package/src/components/consent-gate/consent-gate-standalone.scss +2 -0
- package/src/components/consent-gate/consent-gate.config.js +6 -0
- package/src/components/consent-gate/consent-gate.js +289 -0
- package/src/components/consent-gate/consent-gate.njk +203 -0
- package/src/components/consent-gate/consent-gate.scss +138 -0
- package/src/components/consent-gate/consent-gate.spec.js +582 -0
- package/src/components/consent-gate/consent-store.js +109 -0
- package/src/components/consent-gate/consent-store.spec.js +164 -0
- package/src/components/consent-gate/mds-consent-gate-standalone.js +40 -0
- package/src/helpers/fluid-video/README.md +8 -0
- package/src/helpers/prose/prose.js +6 -1
- package/src/js/index.js +5 -0
- package/src/scss/components/__index.scss +1 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- browser-only module */
|
|
2
|
+
/**
|
|
3
|
+
* `<mds-consent-gate>` — generic consent gate for any third-party content.
|
|
4
|
+
*
|
|
5
|
+
* Wraps arbitrary HTML in a `<template>` and keeps it inert until the user
|
|
6
|
+
* explicitly grants consent for the given `category`. On grant, the template
|
|
7
|
+
* is cloned into the mount point. On revoke, the clone is removed (killing
|
|
8
|
+
* any active network connections such as iframes).
|
|
9
|
+
*
|
|
10
|
+
* Integrates with any CMP (e.g. OneTrust) via `setConsentAdapter()`.
|
|
11
|
+
*
|
|
12
|
+
* Public attribute → state attribute mapping is collapsed onto a single
|
|
13
|
+
* `data-state` host attribute so all visibility is driven from CSS:
|
|
14
|
+
* absent — pre-mount / not-yet-upgraded (no-JS fallback)
|
|
15
|
+
* "error" — config error; only [slot=fallback] visible
|
|
16
|
+
* "placeholder" — awaiting consent; placeholder UI visible
|
|
17
|
+
* "consented" — content cloned into [part=content]
|
|
18
|
+
*/
|
|
19
|
+
import { has, grant, subscribe } from './consent-store.js';
|
|
20
|
+
export { setConsentAdapter } from './consent-store.js';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_I18N = Object.freeze({
|
|
23
|
+
acceptLabel: 'Load content',
|
|
24
|
+
consentMessage: 'Loads content from a third-party provider, which may set cookies and collect personal data.',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const STYLE_HOOK_PROPS = {
|
|
28
|
+
'aspect-ratio': '--mds-consent-gate-aspect-ratio',
|
|
29
|
+
'preview-image': '--mds-consent-gate-preview-image',
|
|
30
|
+
};
|
|
31
|
+
const STYLE_ATTRS = Object.keys(STYLE_HOOK_PROPS);
|
|
32
|
+
|
|
33
|
+
/** Direct-child element types that load network resources and must be wrapped in <template>. */
|
|
34
|
+
const BARE_CONTENT_SELECTOR = ':scope > iframe, :scope > embed, :scope > object, :scope > script[src]';
|
|
35
|
+
|
|
36
|
+
let nextId = 0;
|
|
37
|
+
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {Record<string, unknown>} */
|
|
43
|
+
function parsedI18nFromAttribute(raw) {
|
|
44
|
+
if (raw == null || raw === '') return {};
|
|
45
|
+
try {
|
|
46
|
+
const value = JSON.parse(raw);
|
|
47
|
+
return isPlainObject(value) ? value : {};
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class MdsConsentGate extends HTMLElement {
|
|
54
|
+
static observedAttributes = ['category', 'i18n', ...STYLE_ATTRS];
|
|
55
|
+
|
|
56
|
+
#placeholder = null;
|
|
57
|
+
#message = null;
|
|
58
|
+
#accept = null;
|
|
59
|
+
#content = null;
|
|
60
|
+
#headingWrap = null;
|
|
61
|
+
#unsubscribe = null;
|
|
62
|
+
#connected = false;
|
|
63
|
+
|
|
64
|
+
get #mountPoint() {
|
|
65
|
+
return this.shadowRoot ?? this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
constructor() {
|
|
69
|
+
super();
|
|
70
|
+
this.#buildPlaceholder();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
connectedCallback() {
|
|
76
|
+
// Re-parent the authored [slot="heading"] element into the wrapper so both
|
|
77
|
+
// light and shadow DOM paths share the same [part="heading-wrap"] CSS selectors.
|
|
78
|
+
const heading = this.querySelector(':scope > [slot="heading"]');
|
|
79
|
+
if (heading) this.#headingWrap.append(heading);
|
|
80
|
+
this.#applyStyleHooks();
|
|
81
|
+
this.#applyText();
|
|
82
|
+
this.#mountPoint.append(this.#headingWrap, this.#placeholder, this.#content);
|
|
83
|
+
this.#mountPoint.addEventListener('click', this.#onClick);
|
|
84
|
+
this.#unsubscribe = subscribe(this.#onConsentChange);
|
|
85
|
+
this.#checkBareContent();
|
|
86
|
+
this.#connected = true;
|
|
87
|
+
this.#syncState();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
disconnectedCallback() {
|
|
91
|
+
this.#mountPoint.removeEventListener('click', this.#onClick);
|
|
92
|
+
this.#unsubscribe?.();
|
|
93
|
+
this.#unsubscribe = null;
|
|
94
|
+
this.append(...this.#headingWrap.children);
|
|
95
|
+
this.#headingWrap.remove();
|
|
96
|
+
this.#placeholder.remove();
|
|
97
|
+
this.#content.replaceChildren();
|
|
98
|
+
this.#content.remove();
|
|
99
|
+
delete this.dataset.state;
|
|
100
|
+
this.#connected = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
104
|
+
if (oldValue === newValue || !this.#connected) return;
|
|
105
|
+
if (STYLE_ATTRS.includes(name)) {
|
|
106
|
+
this.#applyStyleHook(name, newValue);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (name === 'i18n') {
|
|
110
|
+
this.#applyText();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (name === 'category') {
|
|
114
|
+
this.#content.replaceChildren();
|
|
115
|
+
this.#syncState();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── DOM scaffolding ───────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
#buildPlaceholder() {
|
|
122
|
+
const descId = `mds-consent-gate-msg-${++nextId}`;
|
|
123
|
+
|
|
124
|
+
this.#placeholder = document.createElement('div');
|
|
125
|
+
this.#placeholder.setAttribute('part', 'placeholder');
|
|
126
|
+
|
|
127
|
+
this.#message = document.createElement('p');
|
|
128
|
+
this.#message.id = descId;
|
|
129
|
+
this.#message.setAttribute('part', 'consent-message');
|
|
130
|
+
|
|
131
|
+
this.#accept = document.createElement('button');
|
|
132
|
+
this.#accept.type = 'button';
|
|
133
|
+
this.#accept.className = 'mds-button';
|
|
134
|
+
this.#accept.setAttribute('part', 'accept');
|
|
135
|
+
this.#accept.setAttribute('aria-describedby', descId);
|
|
136
|
+
|
|
137
|
+
const overlay = document.createElement('div');
|
|
138
|
+
overlay.setAttribute('part', 'consent-overlay');
|
|
139
|
+
overlay.append(this.#message, this.#accept);
|
|
140
|
+
|
|
141
|
+
this.#placeholder.append(overlay);
|
|
142
|
+
|
|
143
|
+
this.#content = document.createElement('div');
|
|
144
|
+
this.#content.setAttribute('part', 'content');
|
|
145
|
+
|
|
146
|
+
this.#headingWrap = document.createElement('div');
|
|
147
|
+
this.#headingWrap.setAttribute('part', 'heading-wrap');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
#syncState() {
|
|
153
|
+
const category = this.getAttribute('category');
|
|
154
|
+
if (!category) {
|
|
155
|
+
this.#setError('no-category');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const template = this.#getTemplate();
|
|
160
|
+
if (!template) {
|
|
161
|
+
this.#setError('no-template');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (has(category)) {
|
|
166
|
+
this.#cloneInto(category, template);
|
|
167
|
+
} else {
|
|
168
|
+
this.dataset.state = 'placeholder';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#setError(reason) {
|
|
173
|
+
this.dataset.state = 'error';
|
|
174
|
+
this.#dispatch('mds-consent-gate:error', { reason });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#cloneInto(category, template) {
|
|
178
|
+
const fragment = template.content.cloneNode(true);
|
|
179
|
+
rehydrateScripts(fragment);
|
|
180
|
+
this.#content.replaceChildren(fragment);
|
|
181
|
+
this.dataset.state = 'consented';
|
|
182
|
+
this.#dispatch('mds-consent-gate:grant', { category, container: this.#content });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Style hooks / text ────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
#applyStyleHook(name, value) {
|
|
188
|
+
const property = STYLE_HOOK_PROPS[name];
|
|
189
|
+
if (!property) return;
|
|
190
|
+
if (value === null) {
|
|
191
|
+
this.style.removeProperty(property);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.style.setProperty(property, name === 'preview-image' ? `url("${value}")` : value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#applyStyleHooks() {
|
|
198
|
+
for (const name of STYLE_ATTRS) {
|
|
199
|
+
this.#applyStyleHook(name, this.getAttribute(name));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#applyText() {
|
|
204
|
+
const { acceptLabel, consentMessage } = this.#resolvedI18nStrings();
|
|
205
|
+
this.#message.textContent = consentMessage;
|
|
206
|
+
this.#accept.textContent = acceptLabel;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reads `i18n` JSON from host; merges with defaults.
|
|
211
|
+
* Non-object parse results, non-string values, and empty strings fall back per field.
|
|
212
|
+
*/
|
|
213
|
+
#resolvedI18nStrings() {
|
|
214
|
+
const parsed = parsedI18nFromAttribute(this.getAttribute('i18n'));
|
|
215
|
+
/** @type {Record<string, string>} */
|
|
216
|
+
const out = {};
|
|
217
|
+
for (const key of Object.keys(DEFAULT_I18N)) {
|
|
218
|
+
const v = parsed[key];
|
|
219
|
+
out[key] = typeof v === 'string' && v.length > 0 ? v : DEFAULT_I18N[key];
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Authoring guards ──────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
#getTemplate() {
|
|
227
|
+
return this.querySelector(':scope > template');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#checkBareContent() {
|
|
231
|
+
if (!this.querySelector(BARE_CONTENT_SELECTOR)) return;
|
|
232
|
+
console.warn(
|
|
233
|
+
'[mds-consent-gate] Direct child <iframe>, <script src>, <embed> or <object> found outside ' +
|
|
234
|
+
'<template>. Content has already loaded — wrap in <template> for VPPA/GDPR compliance.',
|
|
235
|
+
);
|
|
236
|
+
this.#dispatch('mds-consent-gate:error', { reason: 'unwrapped-content' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Events ────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
#dispatch(type, detail) {
|
|
242
|
+
this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#onClick = (event) => {
|
|
246
|
+
const category = this.getAttribute('category');
|
|
247
|
+
if (!category) return;
|
|
248
|
+
if (event.target.closest('[part="accept"]')) {
|
|
249
|
+
grant(category);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
#onConsentChange = (category, granted) => {
|
|
254
|
+
const current = this.getAttribute('category');
|
|
255
|
+
if (category !== current) return;
|
|
256
|
+
|
|
257
|
+
const isConsented = this.dataset.state === 'consented';
|
|
258
|
+
|
|
259
|
+
if (granted && !isConsented) {
|
|
260
|
+
const template = this.#getTemplate();
|
|
261
|
+
if (template) this.#cloneInto(current, template);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!granted && isConsented) {
|
|
266
|
+
this.#content.replaceChildren();
|
|
267
|
+
this.dataset.state = 'placeholder';
|
|
268
|
+
this.#dispatch('mds-consent-gate:revoke', { category: current });
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Replace <script> elements in a DocumentFragment with fresh ones so they
|
|
277
|
+
* execute when inserted. Cloned template scripts have their "already started"
|
|
278
|
+
* flag set per spec and do not execute otherwise.
|
|
279
|
+
*/
|
|
280
|
+
function rehydrateScripts(fragment) {
|
|
281
|
+
for (const script of [...fragment.querySelectorAll('script')]) {
|
|
282
|
+
const fresh = document.createElement('script');
|
|
283
|
+
for (const { name, value } of script.attributes) {
|
|
284
|
+
fresh.setAttribute(name, value);
|
|
285
|
+
}
|
|
286
|
+
fresh.textContent = script.textContent;
|
|
287
|
+
script.replaceWith(fresh);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script type="module" src="/js/components/mds-consent-gate-standalone.js"></script>
|
|
2
|
+
|
|
3
|
+
<style>
|
|
4
|
+
.revoke-row { margin-top: 12px; }
|
|
5
|
+
|
|
6
|
+
/* Intentionally gross styles to demonstrate shadow DOM isolation. */
|
|
7
|
+
.isolation-demo [part='consent-message'] {
|
|
8
|
+
background: hotpink !important;
|
|
9
|
+
color: yellow !important;
|
|
10
|
+
font-family: 'Comic Sans MS', cursive !important;
|
|
11
|
+
font-size: 1.5em !important;
|
|
12
|
+
transform: rotate(-3deg) !important;
|
|
13
|
+
}
|
|
14
|
+
.isolation-demo [part='accept'] {
|
|
15
|
+
background: lime !important;
|
|
16
|
+
color: red !important;
|
|
17
|
+
border: 4px dashed blue !important;
|
|
18
|
+
transform: rotate(3deg) !important;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
21
|
+
|
|
22
|
+
{# ── YouTube ─────────────────────────────────────────────────────────────── #}
|
|
23
|
+
<h2>YouTube embed</h2>
|
|
24
|
+
<p>Category: <code>youtube</code>. Defers the YouTube embed and tracking requests until consent.</p>
|
|
25
|
+
<mds-consent-gate category="youtube" aspect-ratio="16 / 9">
|
|
26
|
+
<span slot="heading">Big Buck Bunny (Blender Foundation)</span>
|
|
27
|
+
<template>
|
|
28
|
+
<iframe
|
|
29
|
+
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
|
|
30
|
+
title="Big Buck Bunny (Blender Foundation)"
|
|
31
|
+
loading="lazy"
|
|
32
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
33
|
+
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
|
34
|
+
allowfullscreen
|
|
35
|
+
style="width:100%;height:100%;border:0"
|
|
36
|
+
></iframe>
|
|
37
|
+
</template>
|
|
38
|
+
<a slot="fallback" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">Watch on YouTube</a>
|
|
39
|
+
</mds-consent-gate>
|
|
40
|
+
|
|
41
|
+
{# ── YouTube with preview image ──────────────────────────────────────────── #}
|
|
42
|
+
<h2>YouTube (nocookie) with self-hosted preview image (4:3 aspect ratio)</h2>
|
|
43
|
+
<p>
|
|
44
|
+
A self-hosted thumbnail avoids any third-party request before consent. Use a locally-stored
|
|
45
|
+
screenshot or video thumbnail exported from your CMS.
|
|
46
|
+
</p>
|
|
47
|
+
<mds-consent-gate
|
|
48
|
+
category="youtube"
|
|
49
|
+
aspect-ratio="4 / 3"
|
|
50
|
+
preview-image="/assets/images/image-cropper-example-wide.jpg"
|
|
51
|
+
>
|
|
52
|
+
<span slot="heading">Big Buck Bunny (Blender Foundation)</span>
|
|
53
|
+
<template>
|
|
54
|
+
<iframe
|
|
55
|
+
src="https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ"
|
|
56
|
+
title="Big Buck Bunny (Blender Foundation)"
|
|
57
|
+
loading="lazy"
|
|
58
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
59
|
+
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
|
60
|
+
allowfullscreen
|
|
61
|
+
style="width:100%;height:100%;border:0"
|
|
62
|
+
></iframe>
|
|
63
|
+
</template>
|
|
64
|
+
<a slot="fallback" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">Watch on YouTube</a>
|
|
65
|
+
</mds-consent-gate>
|
|
66
|
+
|
|
67
|
+
<p class="revoke-row">
|
|
68
|
+
<button type="button" data-revoke-category="youtube">Revoke YouTube consent</button>
|
|
69
|
+
</p>
|
|
70
|
+
|
|
71
|
+
{# ── Vimeo ───────────────────────────────────────────────────────────────── #}
|
|
72
|
+
<h2>Vimeo embed</h2>
|
|
73
|
+
<p>Category: <code>vimeo</code>. Use <code>?dnt=1</code> on the embed URL to opt out of Vimeo tracking.</p>
|
|
74
|
+
<mds-consent-gate category="vimeo" aspect-ratio="16 / 9">
|
|
75
|
+
<span slot="heading">The Mountain (Terje Sorgjerd)</span>
|
|
76
|
+
<template>
|
|
77
|
+
<iframe
|
|
78
|
+
src="https://player.vimeo.com/video/76979871?dnt=1"
|
|
79
|
+
title="The Mountain (Terje Sorgjerd)"
|
|
80
|
+
loading="lazy"
|
|
81
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
82
|
+
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
|
83
|
+
allowfullscreen
|
|
84
|
+
style="width:100%;height:100%;border:0"
|
|
85
|
+
></iframe>
|
|
86
|
+
</template>
|
|
87
|
+
<a slot="fallback" href="https://vimeo.com/76979871">Watch on Vimeo</a>
|
|
88
|
+
</mds-consent-gate>
|
|
89
|
+
|
|
90
|
+
<p class="revoke-row">
|
|
91
|
+
<button type="button" data-revoke-category="vimeo">Revoke Vimeo consent</button>
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
{# ── Google Maps ─────────────────────────────────────────────────────────── #}
|
|
95
|
+
<h2>Google Maps embed</h2>
|
|
96
|
+
<p>Category: <code>google-maps</code>. Defers the Maps tile/tracking requests until consent.</p>
|
|
97
|
+
<mds-consent-gate
|
|
98
|
+
category="google-maps"
|
|
99
|
+
aspect-ratio="16 / 9"
|
|
100
|
+
preview-image="/assets/images/royal-pavilion-brighton.jpg"
|
|
101
|
+
>
|
|
102
|
+
<span slot="heading">Brighton Royal Pavilion</span>
|
|
103
|
+
<template>
|
|
104
|
+
<iframe
|
|
105
|
+
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2519.8796057891!2d-0.1383!3d50.8218!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x4875850af1a46c71%3A0xde3e07af1ef5d8cb!2sBrighton%20Royal%20Pavilion!5e0!3m2!1sen!2suk!4v1700000000000!5m2!1sen!2suk"
|
|
106
|
+
title="Brighton Royal Pavilion on Google Maps"
|
|
107
|
+
loading="lazy"
|
|
108
|
+
referrerpolicy="no-referrer-when-downgrade"
|
|
109
|
+
allowfullscreen
|
|
110
|
+
style="width:100%;height:100%;border:0"
|
|
111
|
+
></iframe>
|
|
112
|
+
</template>
|
|
113
|
+
<a slot="fallback" href="https://maps.google.com/?q=Brighton+Royal+Pavilion">View on Google Maps</a>
|
|
114
|
+
</mds-consent-gate>
|
|
115
|
+
|
|
116
|
+
<p class="revoke-row">
|
|
117
|
+
<button type="button" data-revoke-category="google-maps">Revoke Google Maps consent</button>
|
|
118
|
+
</p>
|
|
119
|
+
|
|
120
|
+
{# ── Internationalisation ────────────────────────────────────────────────── #}
|
|
121
|
+
<h2>Internationalisation (French)</h2>
|
|
122
|
+
<p>
|
|
123
|
+
Localise the placeholder with the <code>i18n</code> attribute (JSON object with
|
|
124
|
+
<code>acceptLabel</code> and <code>consentMessage</code>). The host language
|
|
125
|
+
(e.g. via <code><html lang></code>) is left to the page.
|
|
126
|
+
</p>
|
|
127
|
+
<mds-consent-gate
|
|
128
|
+
category="youtube-fr"
|
|
129
|
+
aspect-ratio="16 / 9"
|
|
130
|
+
i18n='{"acceptLabel":"Charger le contenu","consentMessage":"Charge un contenu d’un fournisseur tiers, qui peut déposer des cookies et collecter des données personnelles."}'
|
|
131
|
+
lang="fr"
|
|
132
|
+
>
|
|
133
|
+
<span slot="heading">Big Buck Bunny (Fondation Blender)</span>
|
|
134
|
+
<template>
|
|
135
|
+
<iframe
|
|
136
|
+
src="https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?hl=fr&cc_lang_pref=fr"
|
|
137
|
+
title="Big Buck Bunny (Fondation Blender)"
|
|
138
|
+
loading="lazy"
|
|
139
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
140
|
+
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
|
141
|
+
allowfullscreen
|
|
142
|
+
style="width:100%;height:100%;border:0"
|
|
143
|
+
></iframe>
|
|
144
|
+
</template>
|
|
145
|
+
<a slot="fallback" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">Voir sur YouTube</a>
|
|
146
|
+
</mds-consent-gate>
|
|
147
|
+
|
|
148
|
+
<p class="revoke-row">
|
|
149
|
+
<button type="button" data-revoke-category="youtube-fr">Revoke demo consent</button>
|
|
150
|
+
</p>
|
|
151
|
+
|
|
152
|
+
{# ── Style isolation demo ────────────────────────────────────────────────── #}
|
|
153
|
+
<h2>Style isolation: light DOM vs standalone</h2>
|
|
154
|
+
<p>
|
|
155
|
+
This page has intentionally gross CSS rules targeting <code>[part='consent-message']</code>
|
|
156
|
+
and <code>[part='accept']</code>. The light-DOM version inherits them; the standalone version
|
|
157
|
+
is isolated inside its shadow root and should be unaffected.
|
|
158
|
+
</p>
|
|
159
|
+
|
|
160
|
+
<div class="isolation-demo" style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;">
|
|
161
|
+
<div>
|
|
162
|
+
<h3>Light DOM <code><mds-consent-gate></code></h3>
|
|
163
|
+
<p>Inherits page CSS — gross styles visible.</p>
|
|
164
|
+
<mds-consent-gate category="youtube-isolation-demo" aspect-ratio="16 / 9">
|
|
165
|
+
<span slot="heading">Light DOM</span>
|
|
166
|
+
<template>
|
|
167
|
+
<iframe
|
|
168
|
+
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
|
|
169
|
+
title="Big Buck Bunny"
|
|
170
|
+
style="width:100%;height:100%;border:0"
|
|
171
|
+
></iframe>
|
|
172
|
+
</template>
|
|
173
|
+
<a slot="fallback" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">Watch on YouTube</a>
|
|
174
|
+
</mds-consent-gate>
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<h3>Standalone <code><mds-consent-gate-standalone></code></h3>
|
|
178
|
+
<p>Shadow DOM isolated — gross styles should not apply.</p>
|
|
179
|
+
<mds-consent-gate-standalone category="youtube-isolation-demo" aspect-ratio="16 / 9">
|
|
180
|
+
<span slot="heading">Standalone</span>
|
|
181
|
+
<template>
|
|
182
|
+
<iframe
|
|
183
|
+
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
|
|
184
|
+
title="Big Buck Bunny"
|
|
185
|
+
style="width:100%;height:100%;border:0"
|
|
186
|
+
></iframe>
|
|
187
|
+
</template>
|
|
188
|
+
<a slot="fallback" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">Watch on YouTube</a>
|
|
189
|
+
</mds-consent-gate-standalone>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<p class="revoke-row">
|
|
194
|
+
<button type="button" data-revoke-category="youtube-isolation-demo">Revoke isolation demo consent</button>
|
|
195
|
+
</p>
|
|
196
|
+
|
|
197
|
+
<script type="module">
|
|
198
|
+
import { revoke } from '/js/components/mds-consent-gate-standalone.js';
|
|
199
|
+
document.addEventListener('click', (event) => {
|
|
200
|
+
const btn = event.target.closest('[data-revoke-category]');
|
|
201
|
+
if (btn) revoke(btn.dataset.revokeCategory);
|
|
202
|
+
});
|
|
203
|
+
</script>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* `<mds-consent-gate>` styles.
|
|
2
|
+
*
|
|
3
|
+
* Two parallel selector tracks share one stylesheet:
|
|
4
|
+
* - `:where(mds-consent-gate, mds-consent-gate-standalone)` — the light-DOM
|
|
5
|
+
* bundle path, where the custom element host *is* the styled box.
|
|
6
|
+
* - `:host` / `::slotted(...)` — adopted into the standalone variant's
|
|
7
|
+
* shadow root. Inert when the same stylesheet is loaded from the main DS
|
|
8
|
+
* bundle (no shadow context, no slotted content).
|
|
9
|
+
*
|
|
10
|
+
* Visibility is driven entirely by a single host data-state attribute:
|
|
11
|
+
* absent — pre-mount / no-JS: only [slot=fallback] visible
|
|
12
|
+
* "error" — config error: only [slot=fallback] visible
|
|
13
|
+
* "placeholder" — awaiting consent: heading + placeholder + fallback
|
|
14
|
+
* "consented" — content cloned: only [part=content] visible
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* ─── Frame (placeholder + error states) ─────────────────────────────────── */
|
|
18
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='placeholder'],
|
|
19
|
+
:host([data-state='placeholder']) {
|
|
20
|
+
--mds-consent-gate-bg: var(--mds-color-neutral-darkest, #000);
|
|
21
|
+
--mds-consent-gate-overlay-bg: rgb(0 0 0 / 0.65);
|
|
22
|
+
|
|
23
|
+
display: block;
|
|
24
|
+
position: relative;
|
|
25
|
+
aspect-ratio: var(--mds-consent-gate-aspect-ratio);
|
|
26
|
+
background-color: var(--mds-consent-gate-bg);
|
|
27
|
+
background-image: var(--mds-consent-gate-preview-image, none);
|
|
28
|
+
background-size: cover;
|
|
29
|
+
background-position: center;
|
|
30
|
+
color: var(--mds-color-text-invert, #fff);
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ─── Pre-mount / error: collapse, fallback link only ────────────────────── */
|
|
35
|
+
:where(mds-consent-gate, mds-consent-gate-standalone):not([data-state]),
|
|
36
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='error'],
|
|
37
|
+
:host(:not([data-state])),
|
|
38
|
+
:host([data-state='error']) {
|
|
39
|
+
display: contents;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
:where(mds-consent-gate, mds-consent-gate-standalone):not([data-state]) > :not([slot='fallback']),
|
|
43
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='error'] > :not([slot='fallback']) {
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
:where(mds-consent-gate, mds-consent-gate-standalone):not([data-state]) > [slot='fallback'],
|
|
48
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='error'] > [slot='fallback'] {
|
|
49
|
+
all: revert;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* In shadow context the only authored children are slotted; everything in the
|
|
53
|
+
shadow tree (placeholder, content, heading slot) gets hidden via the wrapping
|
|
54
|
+
`display: contents` above. The fallback slot still projects. */
|
|
55
|
+
|
|
56
|
+
/* ─── Consented: show only content ───────────────────────────────────────── */
|
|
57
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='consented'],
|
|
58
|
+
:host([data-state='consented']) {
|
|
59
|
+
display: block;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='consented'] > [slot='heading'],
|
|
63
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='consented'] > [slot='fallback'],
|
|
64
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='consented'] > template,
|
|
65
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='consented'] > [part='placeholder'],
|
|
66
|
+
:host([data-state='consented']) [part='heading-wrap'],
|
|
67
|
+
:host([data-state='consented']) ::slotted([slot='fallback']),
|
|
68
|
+
:host([data-state='consented']) [part='placeholder'] {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ─── Placeholder UI (only painted when [data-state=placeholder]) ────────── */
|
|
73
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='placeholder']
|
|
74
|
+
> :not([part='placeholder']):not([part='heading-wrap']):not([slot='heading']):not([slot='fallback']),
|
|
75
|
+
:host([data-state='placeholder']) [part='content'] {
|
|
76
|
+
display: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[part='placeholder'] {
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: 0;
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: center;
|
|
86
|
+
padding: var(--mds-size-spacing-base, 1rem);
|
|
87
|
+
text-align: center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ─── Shared overlay backdrop (consent-overlay, heading, fallback) ──────── */
|
|
91
|
+
[part='consent-overlay'],
|
|
92
|
+
[part='heading-wrap'],
|
|
93
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='placeholder'] > [slot='fallback'],
|
|
94
|
+
:host([data-state='placeholder']) ::slotted([slot='fallback']) {
|
|
95
|
+
padding: 0.625rem 1rem;
|
|
96
|
+
background: var(--mds-consent-gate-overlay-bg);
|
|
97
|
+
border-radius: var(--mds-size-border-radius-base, 4px);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
[part='consent-overlay'] {
|
|
101
|
+
max-width: 40ch;
|
|
102
|
+
z-index: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[part='consent-message'] {
|
|
106
|
+
margin: 0 0 0.75rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ─── Authored heading / fallback positioning ────────────────────────────── */
|
|
110
|
+
[part='heading-wrap'] {
|
|
111
|
+
position: absolute;
|
|
112
|
+
top: 5px;
|
|
113
|
+
left: 5px;
|
|
114
|
+
z-index: 1;
|
|
115
|
+
display: block;
|
|
116
|
+
margin: 0;
|
|
117
|
+
font-weight: var(--mds-font-weight-heading-3);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
:where(mds-consent-gate, mds-consent-gate-standalone)[data-state='placeholder'] > [slot='fallback'],
|
|
121
|
+
::slotted([slot='fallback']) {
|
|
122
|
+
position: absolute;
|
|
123
|
+
bottom: 5px;
|
|
124
|
+
right: 5px;
|
|
125
|
+
z-index: 1;
|
|
126
|
+
color: inherit;
|
|
127
|
+
font-size: var(--mds-font-type-s-1-size);
|
|
128
|
+
line-height: var(--mds-font-type-s-1-line-height);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ─── Content frame (consented state) ────────────────────────────────────── */
|
|
132
|
+
[part='content'] {
|
|
133
|
+
display: block;
|
|
134
|
+
width: 100%;
|
|
135
|
+
aspect-ratio: var(--mds-consent-gate-aspect-ratio);
|
|
136
|
+
position: relative;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
}
|