@madgex/design-system 14.1.0 → 14.3.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/conditional-section/README.md +2 -2
- package/src/components/conditional-section/conditional-section.config.js +54 -0
- package/src/components/conditional-section/conditional-section.js +91 -26
- package/src/components/conditional-section/conditional-section.spec.js +402 -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,582 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- jsdom test env */
|
|
2
|
+
// eslint-disable-next-line n/no-unpublished-import
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
4
|
+
import { MdsConsentGate } from './consent-gate.js';
|
|
5
|
+
import { grant, revoke, setConsentAdapter } from './consent-store.js';
|
|
6
|
+
|
|
7
|
+
if (!customElements.get('mds-consent-gate')) {
|
|
8
|
+
customElements.define('mds-consent-gate', MdsConsentGate);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CATEGORY = 'test-provider';
|
|
12
|
+
const EMBED_URL = 'https://example.com/embed/abc';
|
|
13
|
+
|
|
14
|
+
function gateHtml({ category = CATEGORY, embedUrl = EMBED_URL, extra = '' } = {}) {
|
|
15
|
+
return `
|
|
16
|
+
<mds-consent-gate category="${category}" ${extra}>
|
|
17
|
+
<template><iframe src="${embedUrl}" title="Test embed"></iframe></template>
|
|
18
|
+
<a slot="fallback" href="https://example.com">View on Example</a>
|
|
19
|
+
</mds-consent-gate>
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('MdsConsentGate', () => {
|
|
24
|
+
let container;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
container = document.createElement('div');
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
revoke(CATEGORY);
|
|
30
|
+
revoke('other-provider');
|
|
31
|
+
localStorage.clear();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
container.remove();
|
|
36
|
+
revoke(CATEGORY);
|
|
37
|
+
revoke('other-provider');
|
|
38
|
+
setConsentAdapter(null);
|
|
39
|
+
localStorage.clear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('skeleton', () => {
|
|
43
|
+
it('registers as the mds-consent-gate custom element', () => {
|
|
44
|
+
expect(customElements.get('mds-consent-gate')).toBe(MdsConsentGate);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('mounts in light DOM', () => {
|
|
48
|
+
container.innerHTML = gateHtml();
|
|
49
|
+
const el = container.querySelector('mds-consent-gate');
|
|
50
|
+
expect(el.shadowRoot).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('attaches a [part="placeholder"] and [part="content"] in light DOM', () => {
|
|
54
|
+
container.innerHTML = gateHtml();
|
|
55
|
+
const el = container.querySelector('mds-consent-gate');
|
|
56
|
+
expect(el.querySelector('[part="placeholder"]')).toBeTruthy();
|
|
57
|
+
expect(el.querySelector('[part="content"]')).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('pre-consent', () => {
|
|
62
|
+
it('does not clone template content into [part=content]', () => {
|
|
63
|
+
container.innerHTML = gateHtml();
|
|
64
|
+
const el = container.querySelector('mds-consent-gate');
|
|
65
|
+
expect(el.querySelector('[part="content"]').children.length).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('VPPA invariant: no <iframe> anywhere in document before consent', () => {
|
|
69
|
+
container.innerHTML = gateHtml();
|
|
70
|
+
expect(document.querySelectorAll('iframe').length).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sets data-state="placeholder"', () => {
|
|
74
|
+
container.innerHTML = gateHtml();
|
|
75
|
+
const el = container.querySelector('mds-consent-gate');
|
|
76
|
+
expect(el.dataset.state).toBe('placeholder');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('placeholder', () => {
|
|
81
|
+
it('renders a consent message', () => {
|
|
82
|
+
container.innerHTML = gateHtml();
|
|
83
|
+
const el = container.querySelector('mds-consent-gate');
|
|
84
|
+
const msg = el.querySelector('[part="consent-message"]');
|
|
85
|
+
expect(msg).toBeTruthy();
|
|
86
|
+
expect(msg.textContent.trim()).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders an Accept button with default label', () => {
|
|
90
|
+
container.innerHTML = gateHtml();
|
|
91
|
+
const el = container.querySelector('mds-consent-gate');
|
|
92
|
+
const btn = el.querySelector('button[part="accept"]');
|
|
93
|
+
expect(btn).toBeTruthy();
|
|
94
|
+
expect(btn.textContent.trim()).toBe('Load content');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('renders custom acceptLabel via i18n', () => {
|
|
98
|
+
container.innerHTML = gateHtml({
|
|
99
|
+
extra: `i18n='${JSON.stringify({ acceptLabel: 'Watch video' })}'`,
|
|
100
|
+
});
|
|
101
|
+
const el = container.querySelector('mds-consent-gate');
|
|
102
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Watch video');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('renders custom consentMessage via i18n', () => {
|
|
106
|
+
container.innerHTML = gateHtml({
|
|
107
|
+
extra: `i18n='${JSON.stringify({ consentMessage: 'Custom message' })}'`,
|
|
108
|
+
});
|
|
109
|
+
const el = container.querySelector('mds-consent-gate');
|
|
110
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe('Custom message');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('uses defaults when i18n JSON is invalid', () => {
|
|
114
|
+
container.innerHTML = gateHtml({ extra: `i18n='not-json'` });
|
|
115
|
+
const el = container.querySelector('mds-consent-gate');
|
|
116
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Load content');
|
|
117
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe(
|
|
118
|
+
'Loads content from a third-party provider, which may set cookies and collect personal data.',
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('partial i18n object uses defaults for missing keys', () => {
|
|
123
|
+
container.innerHTML = gateHtml({
|
|
124
|
+
extra: `i18n='${JSON.stringify({ acceptLabel: 'Go' })}'`,
|
|
125
|
+
});
|
|
126
|
+
const el = container.querySelector('mds-consent-gate');
|
|
127
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Go');
|
|
128
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe(
|
|
129
|
+
'Loads content from a third-party provider, which may set cookies and collect personal data.',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('empty string in i18n falls back to default for that field', () => {
|
|
134
|
+
container.innerHTML = gateHtml({
|
|
135
|
+
extra: `i18n='${JSON.stringify({ acceptLabel: '', consentMessage: 'Only body' })}'`,
|
|
136
|
+
});
|
|
137
|
+
const el = container.querySelector('mds-consent-gate');
|
|
138
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Load content');
|
|
139
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe('Only body');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('non-string i18n values fall back to defaults', () => {
|
|
143
|
+
container.innerHTML = gateHtml({
|
|
144
|
+
extra: `i18n='${JSON.stringify({ acceptLabel: 42, consentMessage: null })}'`,
|
|
145
|
+
});
|
|
146
|
+
const el = container.querySelector('mds-consent-gate');
|
|
147
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Load content');
|
|
148
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe(
|
|
149
|
+
'Loads content from a third-party provider, which may set cookies and collect personal data.',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('parses array JSON as no overrides (defaults)', () => {
|
|
154
|
+
container.innerHTML = gateHtml({ extra: `i18n='[1,2]'` });
|
|
155
|
+
const el = container.querySelector('mds-consent-gate');
|
|
156
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Load content');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles consentMessage containing double quotes', () => {
|
|
160
|
+
container.innerHTML = gateHtml({
|
|
161
|
+
extra: `i18n='${JSON.stringify({ consentMessage: 'Say "yes" to load' })}'`,
|
|
162
|
+
});
|
|
163
|
+
const el = container.querySelector('mds-consent-gate');
|
|
164
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe('Say "yes" to load');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('Accept button is described by consent-message for a11y', () => {
|
|
168
|
+
container.innerHTML = gateHtml();
|
|
169
|
+
const el = container.querySelector('mds-consent-gate');
|
|
170
|
+
const btn = el.querySelector('button[part="accept"]');
|
|
171
|
+
const describedBy = btn.getAttribute('aria-describedby');
|
|
172
|
+
expect(describedBy).toBeTruthy();
|
|
173
|
+
expect(el.querySelector(`#${describedBy}`)).toBeTruthy();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('updates i18n live via setAttribute', () => {
|
|
177
|
+
container.innerHTML = gateHtml();
|
|
178
|
+
const el = container.querySelector('mds-consent-gate');
|
|
179
|
+
el.setAttribute('i18n', JSON.stringify({ acceptLabel: 'Hello', consentMessage: 'World' }));
|
|
180
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Hello');
|
|
181
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe('World');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('removeAttribute(i18n) restores defaults', () => {
|
|
185
|
+
container.innerHTML = gateHtml({
|
|
186
|
+
extra: `i18n='${JSON.stringify({ acceptLabel: 'Temp', consentMessage: 'Temp msg' })}'`,
|
|
187
|
+
});
|
|
188
|
+
const el = container.querySelector('mds-consent-gate');
|
|
189
|
+
el.removeAttribute('i18n');
|
|
190
|
+
expect(el.querySelector('button[part="accept"]').textContent.trim()).toBe('Load content');
|
|
191
|
+
expect(el.querySelector('[part="consent-message"]').textContent.trim()).toBe(
|
|
192
|
+
'Loads content from a third-party provider, which may set cookies and collect personal data.',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('heading slot', () => {
|
|
198
|
+
it('places heading slot content inside [part="heading-wrap"]', () => {
|
|
199
|
+
container.innerHTML = `
|
|
200
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
201
|
+
<span slot="heading">My heading</span>
|
|
202
|
+
<template><iframe src="${EMBED_URL}" title="Test"></iframe></template>
|
|
203
|
+
<a slot="fallback" href="https://example.com">Fallback</a>
|
|
204
|
+
</mds-consent-gate>
|
|
205
|
+
`;
|
|
206
|
+
const el = container.querySelector('mds-consent-gate');
|
|
207
|
+
const wrap = el.querySelector(':scope > [part="heading-wrap"]');
|
|
208
|
+
expect(wrap).toBeTruthy();
|
|
209
|
+
const heading = wrap.querySelector('[slot="heading"]');
|
|
210
|
+
expect(heading).toBeTruthy();
|
|
211
|
+
expect(heading.textContent).toBe('My heading');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('fallback slot', () => {
|
|
216
|
+
it('keeps fallback slot content as a direct child of host', () => {
|
|
217
|
+
container.innerHTML = gateHtml();
|
|
218
|
+
const el = container.querySelector('mds-consent-gate');
|
|
219
|
+
const fallback = el.querySelector(':scope > [slot="fallback"]');
|
|
220
|
+
expect(fallback).toBeTruthy();
|
|
221
|
+
expect(fallback.parentElement).toBe(el);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('clicking Accept', () => {
|
|
226
|
+
it('calls grant(category) on Accept click', () => {
|
|
227
|
+
container.innerHTML = gateHtml();
|
|
228
|
+
const el = container.querySelector('mds-consent-gate');
|
|
229
|
+
el.querySelector('button[part="accept"]').click();
|
|
230
|
+
expect(localStorage.getItem('mds-consent')).toContain(CATEGORY);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('after grant', () => {
|
|
235
|
+
it('clones <template> content into [part="content"]', () => {
|
|
236
|
+
container.innerHTML = gateHtml();
|
|
237
|
+
const el = container.querySelector('mds-consent-gate');
|
|
238
|
+
el.querySelector('button[part="accept"]').click();
|
|
239
|
+
|
|
240
|
+
const content = el.querySelector('[part="content"]');
|
|
241
|
+
expect(content.querySelector('iframe')).toBeTruthy();
|
|
242
|
+
expect(content.querySelector('iframe').getAttribute('src')).toBe(EMBED_URL);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('iframe is in document after grant', () => {
|
|
246
|
+
container.innerHTML = gateHtml();
|
|
247
|
+
const el = container.querySelector('mds-consent-gate');
|
|
248
|
+
el.querySelector('button[part="accept"]').click();
|
|
249
|
+
expect(document.querySelectorAll('iframe').length).toBe(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('sets data-state="consented"', () => {
|
|
253
|
+
container.innerHTML = gateHtml();
|
|
254
|
+
const el = container.querySelector('mds-consent-gate');
|
|
255
|
+
el.querySelector('button[part="accept"]').click();
|
|
256
|
+
expect(el.dataset.state).toBe('consented');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('script rehydration', () => {
|
|
261
|
+
it('<script> inside <template> is rehydrated into a fresh element after grant', () => {
|
|
262
|
+
container.innerHTML = `
|
|
263
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
264
|
+
<template><script data-rehydrate-test>window.__mdsConsentGateTest = true;</script></template>
|
|
265
|
+
</mds-consent-gate>
|
|
266
|
+
`;
|
|
267
|
+
const el = container.querySelector('mds-consent-gate');
|
|
268
|
+
el.querySelector('button[part="accept"]').click();
|
|
269
|
+
const content = el.querySelector('[part="content"]');
|
|
270
|
+
const script = content.querySelector('script[data-rehydrate-test]');
|
|
271
|
+
expect(script).toBeTruthy();
|
|
272
|
+
const original = el.querySelector(':scope > template').content.querySelector('script');
|
|
273
|
+
expect(script).not.toBe(original);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('mds-consent-gate:grant event', () => {
|
|
278
|
+
it('fires with { category, container } on accept', () => {
|
|
279
|
+
container.innerHTML = gateHtml();
|
|
280
|
+
const el = container.querySelector('mds-consent-gate');
|
|
281
|
+
|
|
282
|
+
const events = [];
|
|
283
|
+
el.addEventListener('mds-consent-gate:grant', (e) => events.push(e.detail));
|
|
284
|
+
el.querySelector('button[part="accept"]').click();
|
|
285
|
+
|
|
286
|
+
expect(events.length).toBe(1);
|
|
287
|
+
expect(events[0].category).toBe(CATEGORY);
|
|
288
|
+
expect(events[0].container).toBeTruthy();
|
|
289
|
+
expect(events[0].container.querySelector('iframe')).toBeTruthy();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('event bubbles and is composed', () => {
|
|
293
|
+
container.innerHTML = gateHtml();
|
|
294
|
+
const el = container.querySelector('mds-consent-gate');
|
|
295
|
+
|
|
296
|
+
let bubbled = false;
|
|
297
|
+
document.addEventListener(
|
|
298
|
+
'mds-consent-gate:grant',
|
|
299
|
+
() => {
|
|
300
|
+
bubbled = true;
|
|
301
|
+
},
|
|
302
|
+
{ once: true },
|
|
303
|
+
);
|
|
304
|
+
el.querySelector('button[part="accept"]').click();
|
|
305
|
+
expect(bubbled).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('revoke', () => {
|
|
310
|
+
it('removes cloned content on revoke', () => {
|
|
311
|
+
container.innerHTML = gateHtml();
|
|
312
|
+
const el = container.querySelector('mds-consent-gate');
|
|
313
|
+
el.querySelector('button[part="accept"]').click();
|
|
314
|
+
expect(el.querySelector('[part="content"]').children.length).toBeGreaterThan(0);
|
|
315
|
+
|
|
316
|
+
revoke(CATEGORY);
|
|
317
|
+
|
|
318
|
+
expect(el.querySelector('[part="content"]').children.length).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('flips data-state back to "placeholder" on revoke', () => {
|
|
322
|
+
container.innerHTML = gateHtml();
|
|
323
|
+
const el = container.querySelector('mds-consent-gate');
|
|
324
|
+
el.querySelector('button[part="accept"]').click();
|
|
325
|
+
revoke(CATEGORY);
|
|
326
|
+
expect(el.dataset.state).toBe('placeholder');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('fires mds-consent-gate:revoke with { category }', () => {
|
|
330
|
+
container.innerHTML = gateHtml();
|
|
331
|
+
const el = container.querySelector('mds-consent-gate');
|
|
332
|
+
el.querySelector('button[part="accept"]').click();
|
|
333
|
+
|
|
334
|
+
const events = [];
|
|
335
|
+
el.addEventListener('mds-consent-gate:revoke', (e) => events.push(e.detail));
|
|
336
|
+
revoke(CATEGORY);
|
|
337
|
+
|
|
338
|
+
expect(events.length).toBe(1);
|
|
339
|
+
expect(events[0].category).toBe(CATEGORY);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('iframe is removed from document after revoke', () => {
|
|
343
|
+
container.innerHTML = gateHtml();
|
|
344
|
+
const el = container.querySelector('mds-consent-gate');
|
|
345
|
+
el.querySelector('button[part="accept"]').click();
|
|
346
|
+
revoke(CATEGORY);
|
|
347
|
+
expect(document.querySelectorAll('iframe').length).toBe(0);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('no <template> child', () => {
|
|
352
|
+
it('sets data-state="error" with no Accept button', () => {
|
|
353
|
+
container.innerHTML = `
|
|
354
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
355
|
+
<a slot="fallback" href="https://example.com">Fallback</a>
|
|
356
|
+
</mds-consent-gate>
|
|
357
|
+
`;
|
|
358
|
+
const el = container.querySelector('mds-consent-gate');
|
|
359
|
+
expect(el.dataset.state).toBe('error');
|
|
360
|
+
expect(el.querySelector(':scope > [slot="fallback"]')).toBeTruthy();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('dispatches mds-consent-gate:error with reason no-template', () => {
|
|
364
|
+
const events = [];
|
|
365
|
+
const handler = (e) => events.push(e.detail);
|
|
366
|
+
document.addEventListener('mds-consent-gate:error', handler);
|
|
367
|
+
container.innerHTML = `
|
|
368
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
369
|
+
<a slot="fallback" href="https://example.com">Fallback</a>
|
|
370
|
+
</mds-consent-gate>
|
|
371
|
+
`;
|
|
372
|
+
document.removeEventListener('mds-consent-gate:error', handler);
|
|
373
|
+
expect(events.length).toBe(1);
|
|
374
|
+
expect(events[0].reason).toBe('no-template');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('no category attribute', () => {
|
|
379
|
+
it('sets data-state="error"', () => {
|
|
380
|
+
container.innerHTML = `
|
|
381
|
+
<mds-consent-gate>
|
|
382
|
+
<template><iframe src="${EMBED_URL}" title="Test"></iframe></template>
|
|
383
|
+
<a slot="fallback" href="https://example.com">Fallback</a>
|
|
384
|
+
</mds-consent-gate>
|
|
385
|
+
`;
|
|
386
|
+
const el = container.querySelector('mds-consent-gate');
|
|
387
|
+
expect(el.dataset.state).toBe('error');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('dispatches mds-consent-gate:error with reason no-category', () => {
|
|
391
|
+
const events = [];
|
|
392
|
+
const handler = (e) => events.push(e.detail);
|
|
393
|
+
document.addEventListener('mds-consent-gate:error', handler);
|
|
394
|
+
container.innerHTML = `
|
|
395
|
+
<mds-consent-gate>
|
|
396
|
+
<template><iframe src="${EMBED_URL}" title="Test"></iframe></template>
|
|
397
|
+
</mds-consent-gate>
|
|
398
|
+
`;
|
|
399
|
+
document.removeEventListener('mds-consent-gate:error', handler);
|
|
400
|
+
expect(events.length).toBe(1);
|
|
401
|
+
expect(events[0].reason).toBe('no-category');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('bare-content guard', () => {
|
|
406
|
+
it('warns and dispatches error for direct-child <iframe> outside <template>', () => {
|
|
407
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
408
|
+
const events = [];
|
|
409
|
+
const handler = (e) => events.push(e.detail);
|
|
410
|
+
document.addEventListener('mds-consent-gate:error', handler);
|
|
411
|
+
|
|
412
|
+
container.innerHTML = `
|
|
413
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
414
|
+
<iframe src="${EMBED_URL}" title="Test"></iframe>
|
|
415
|
+
</mds-consent-gate>
|
|
416
|
+
`;
|
|
417
|
+
|
|
418
|
+
document.removeEventListener('mds-consent-gate:error', handler);
|
|
419
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('mds-consent-gate'));
|
|
420
|
+
expect(events.some((e) => e.reason === 'unwrapped-content')).toBe(true);
|
|
421
|
+
warnSpy.mockRestore();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('multiple gates, same category', () => {
|
|
426
|
+
it('single grant reveals all gates with that category', () => {
|
|
427
|
+
container.innerHTML = `
|
|
428
|
+
${gateHtml()}
|
|
429
|
+
${gateHtml()}
|
|
430
|
+
`;
|
|
431
|
+
const [gate1, gate2] = container.querySelectorAll('mds-consent-gate');
|
|
432
|
+
|
|
433
|
+
gate1.querySelector('button[part="accept"]').click();
|
|
434
|
+
|
|
435
|
+
expect(gate1.dataset.state).toBe('consented');
|
|
436
|
+
expect(gate2.dataset.state).toBe('consented');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('template-less gate ignores grant events', () => {
|
|
441
|
+
it('does not re-render or error when category is granted but no template exists', () => {
|
|
442
|
+
container.innerHTML = `
|
|
443
|
+
<mds-consent-gate category="${CATEGORY}">
|
|
444
|
+
<a slot="fallback" href="https://example.com">Fallback</a>
|
|
445
|
+
</mds-consent-gate>
|
|
446
|
+
`;
|
|
447
|
+
const el = container.querySelector('mds-consent-gate');
|
|
448
|
+
const errorEvents = [];
|
|
449
|
+
el.addEventListener('mds-consent-gate:error', (e) => errorEvents.push(e));
|
|
450
|
+
|
|
451
|
+
grant(CATEGORY);
|
|
452
|
+
|
|
453
|
+
expect(el.querySelector('[part="content"]').children.length).toBe(0);
|
|
454
|
+
expect(errorEvents.length).toBe(0);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe('already consented at mount', () => {
|
|
459
|
+
it('clones content synchronously on connect, no placeholder shown', () => {
|
|
460
|
+
grant(CATEGORY);
|
|
461
|
+
container.innerHTML = gateHtml();
|
|
462
|
+
const el = container.querySelector('mds-consent-gate');
|
|
463
|
+
|
|
464
|
+
expect(el.dataset.state).toBe('consented');
|
|
465
|
+
expect(el.querySelector('[part="content"]').querySelector('iframe')).toBeTruthy();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('fires mds-consent-gate:grant on mount when already consented', () => {
|
|
469
|
+
grant(CATEGORY);
|
|
470
|
+
const events = [];
|
|
471
|
+
const handler = (e) => events.push(e.detail);
|
|
472
|
+
document.addEventListener('mds-consent-gate:grant', handler);
|
|
473
|
+
container.innerHTML = gateHtml();
|
|
474
|
+
document.removeEventListener('mds-consent-gate:grant', handler);
|
|
475
|
+
expect(events.length).toBe(1);
|
|
476
|
+
expect(events[0].category).toBe(CATEGORY);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('category attribute change', () => {
|
|
481
|
+
it('removes content and re-evaluates consent for new category', () => {
|
|
482
|
+
grant(CATEGORY);
|
|
483
|
+
container.innerHTML = gateHtml();
|
|
484
|
+
const el = container.querySelector('mds-consent-gate');
|
|
485
|
+
expect(el.dataset.state).toBe('consented');
|
|
486
|
+
|
|
487
|
+
el.setAttribute('category', 'other-provider');
|
|
488
|
+
|
|
489
|
+
expect(el.dataset.state).toBe('placeholder');
|
|
490
|
+
expect(el.querySelector('[part="content"]').children.length).toBe(0);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('clones content when new category is already consented', () => {
|
|
494
|
+
grant('other-provider');
|
|
495
|
+
container.innerHTML = gateHtml();
|
|
496
|
+
const el = container.querySelector('mds-consent-gate');
|
|
497
|
+
expect(el.dataset.state).toBe('placeholder');
|
|
498
|
+
|
|
499
|
+
el.setAttribute('category', 'other-provider');
|
|
500
|
+
expect(el.dataset.state).toBe('consented');
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe('custom adapter (setConsentAdapter)', () => {
|
|
505
|
+
it('gate reacts to programmatic consent changes via custom adapter', () => {
|
|
506
|
+
let listener;
|
|
507
|
+
setConsentAdapter({
|
|
508
|
+
has: () => false,
|
|
509
|
+
grant: () => {},
|
|
510
|
+
revoke: () => {},
|
|
511
|
+
subscribe: (fn) => {
|
|
512
|
+
listener = fn;
|
|
513
|
+
return () => {};
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
container.innerHTML = gateHtml();
|
|
518
|
+
const el = container.querySelector('mds-consent-gate');
|
|
519
|
+
expect(el.dataset.state).toBe('placeholder');
|
|
520
|
+
|
|
521
|
+
listener(CATEGORY, true);
|
|
522
|
+
|
|
523
|
+
expect(el.dataset.state).toBe('consented');
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe('disconnectedCallback', () => {
|
|
528
|
+
it('removes cloned content from light DOM on disconnect', () => {
|
|
529
|
+
grant(CATEGORY);
|
|
530
|
+
container.innerHTML = gateHtml();
|
|
531
|
+
const el = container.querySelector('mds-consent-gate');
|
|
532
|
+
expect(el.querySelector('[part="content"]')?.querySelector('iframe')).toBeTruthy();
|
|
533
|
+
|
|
534
|
+
el.remove();
|
|
535
|
+
expect(el.querySelector('[part="content"]')?.querySelector('iframe')).toBeFalsy();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('unsubscribes from consent changes after disconnect', () => {
|
|
539
|
+
container.innerHTML = gateHtml();
|
|
540
|
+
const el = container.querySelector('mds-consent-gate');
|
|
541
|
+
|
|
542
|
+
el.remove();
|
|
543
|
+
grant(CATEGORY);
|
|
544
|
+
expect(el.dataset.state).not.toBe('consented');
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe('reconnect after consent', () => {
|
|
549
|
+
it('re-clones content on reconnect when category is consented', () => {
|
|
550
|
+
grant(CATEGORY);
|
|
551
|
+
container.innerHTML = gateHtml();
|
|
552
|
+
const el = container.querySelector('mds-consent-gate');
|
|
553
|
+
|
|
554
|
+
el.remove();
|
|
555
|
+
|
|
556
|
+
container.appendChild(el);
|
|
557
|
+
expect(el.dataset.state).toBe('consented');
|
|
558
|
+
expect(el.querySelector('[part="content"]').querySelector('iframe')).toBeTruthy();
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('style hooks', () => {
|
|
563
|
+
it('aspect-ratio attr sets --mds-consent-gate-aspect-ratio', () => {
|
|
564
|
+
container.innerHTML = gateHtml({ extra: 'aspect-ratio="16 / 9"' });
|
|
565
|
+
const el = container.querySelector('mds-consent-gate');
|
|
566
|
+
expect(el.style.getPropertyValue('--mds-consent-gate-aspect-ratio')).toBe('16 / 9');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('preview-image attr sets --mds-consent-gate-preview-image as url()', () => {
|
|
570
|
+
container.innerHTML = gateHtml({ extra: 'preview-image="/img/thumb.jpg"' });
|
|
571
|
+
const el = container.querySelector('mds-consent-gate');
|
|
572
|
+
expect(el.style.getPropertyValue('--mds-consent-gate-preview-image')).toBe('url("/img/thumb.jpg")');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('removing aspect-ratio attr removes the custom property', () => {
|
|
576
|
+
container.innerHTML = gateHtml({ extra: 'aspect-ratio="4 / 3"' });
|
|
577
|
+
const el = container.querySelector('mds-consent-gate');
|
|
578
|
+
el.removeAttribute('aspect-ratio');
|
|
579
|
+
expect(el.style.getPropertyValue('--mds-consent-gate-aspect-ratio')).toBe('');
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- browser-only module */
|
|
2
|
+
/**
|
|
3
|
+
* Per-category consent state for `<mds-consent-gate>`.
|
|
4
|
+
*
|
|
5
|
+
* Default backend persists to `localStorage` under `mds-consent` and emits a
|
|
6
|
+
* window-level `mds-consent:change` event. Replace via `setConsentAdapter()`
|
|
7
|
+
* to delegate to a CMP (e.g. OneTrust). Subscriptions registered via
|
|
8
|
+
* `subscribe()` survive adapter swaps.
|
|
9
|
+
*
|
|
10
|
+
* `has` MUST be synchronous so the element can pick its initial render state
|
|
11
|
+
* without a microtask round-trip.
|
|
12
|
+
*
|
|
13
|
+
* Adapter contract:
|
|
14
|
+
* has(category): boolean — synchronous
|
|
15
|
+
* grant(category): void
|
|
16
|
+
* revoke(category): void
|
|
17
|
+
* subscribe(fn): () => void — fn(category, granted)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const STORAGE_KEY = 'mds-consent';
|
|
21
|
+
const CHANGE_EVENT = 'mds-consent:change';
|
|
22
|
+
|
|
23
|
+
function createDefaultBackend() {
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
let state = read();
|
|
26
|
+
|
|
27
|
+
function read() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
|
|
30
|
+
return raw ? JSON.parse(raw) : {};
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persist() {
|
|
37
|
+
try {
|
|
38
|
+
if (typeof localStorage !== 'undefined') {
|
|
39
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// localStorage may be disabled (private browsing, quota); fail silently.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emit(category, granted) {
|
|
47
|
+
for (const fn of listeners) fn(category, granted);
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: { category, granted } }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
has(category) {
|
|
55
|
+
return state[category] === true;
|
|
56
|
+
},
|
|
57
|
+
grant(category) {
|
|
58
|
+
if (state[category] === true) return;
|
|
59
|
+
state[category] = true;
|
|
60
|
+
persist();
|
|
61
|
+
emit(category, true);
|
|
62
|
+
},
|
|
63
|
+
revoke(category) {
|
|
64
|
+
if (!state[category]) return;
|
|
65
|
+
delete state[category];
|
|
66
|
+
persist();
|
|
67
|
+
emit(category, false);
|
|
68
|
+
},
|
|
69
|
+
subscribe(fn) {
|
|
70
|
+
listeners.add(fn);
|
|
71
|
+
return () => listeners.delete(fn);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const subscribers = new Set();
|
|
77
|
+
function fanout(category, granted) {
|
|
78
|
+
for (const fn of subscribers) fn(category, granted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let active = createDefaultBackend();
|
|
82
|
+
let unbind = active.subscribe(fanout);
|
|
83
|
+
|
|
84
|
+
export function has(category) {
|
|
85
|
+
return active.has(category);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function grant(category) {
|
|
89
|
+
active.grant(category);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function revoke(category) {
|
|
93
|
+
active.revoke(category);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function subscribe(fn) {
|
|
97
|
+
subscribers.add(fn);
|
|
98
|
+
return () => subscribers.delete(fn);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replace the consent backend. Pass `null` to restore the default
|
|
103
|
+
* localStorage backend. Existing `subscribe()` registrations are preserved.
|
|
104
|
+
*/
|
|
105
|
+
export function setConsentAdapter(adapter) {
|
|
106
|
+
unbind?.();
|
|
107
|
+
active = adapter || createDefaultBackend();
|
|
108
|
+
unbind = active.subscribe(fanout);
|
|
109
|
+
}
|