@ecomconsult/consentkit 0.3.2
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/LICENSE +33 -0
- package/README.md +433 -0
- package/npm/core.cjs +67 -0
- package/npm/core.mjs +33 -0
- package/npm/index.cjs +80 -0
- package/npm/index.d.ts +224 -0
- package/npm/index.mjs +47 -0
- package/npm/internal-stub.mjs +83 -0
- package/npm/react.mjs +153 -0
- package/package.json +65 -0
- package/src/ck-core.js +1044 -0
- package/src/ck-locales.js +936 -0
- package/src/ck-saas.js +363 -0
- package/src/ck-ui.js +1421 -0
package/src/ck-ui.js
ADDED
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
/* ConsentKit UI layer. Shadow DOM banner, preferences panel, floating button.
|
|
2
|
+
Talks to the core only through the public API and ck:* events. No imports, no external assets.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE. */
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
var OPT_IN = ['functional', 'analytics', 'marketing'];
|
|
9
|
+
var ALL_CATS = ['necessary'].concat(OPT_IN);
|
|
10
|
+
|
|
11
|
+
/* ---------------------------------------------------------------- i18n */
|
|
12
|
+
|
|
13
|
+
var DICT = {
|
|
14
|
+
en: {
|
|
15
|
+
bannerTitle: 'We use cookies',
|
|
16
|
+
bannerText: 'Necessary cookies keep the site working. Everything else — analytics, marketing, extra features — runs only if you allow it. You can change your mind at any time.',
|
|
17
|
+
more: 'Learn more',
|
|
18
|
+
acceptAll: 'Accept all',
|
|
19
|
+
rejectAll: 'Reject all',
|
|
20
|
+
customize: 'Customize',
|
|
21
|
+
bannerLabel: 'Cookie consent',
|
|
22
|
+
panelTitle: 'Cookie settings',
|
|
23
|
+
panelIntro: 'Choose which cookies you allow. Nothing optional is on until you turn it on.',
|
|
24
|
+
save: 'Save choices',
|
|
25
|
+
close: 'Close',
|
|
26
|
+
alwaysOn: 'always on',
|
|
27
|
+
cookiesIn: 'Cookies in this group',
|
|
28
|
+
noCookies: 'No cookies declared for this group.',
|
|
29
|
+
colName: 'Name',
|
|
30
|
+
colVendor: 'Provider',
|
|
31
|
+
colPurpose: 'Purpose',
|
|
32
|
+
colExpiry: 'Expires',
|
|
33
|
+
floating: 'Cookie settings',
|
|
34
|
+
poweredBy: 'Powered by ConsentKit',
|
|
35
|
+
cat: {
|
|
36
|
+
necessary: {
|
|
37
|
+
title: 'Necessary',
|
|
38
|
+
desc: 'Needed for the site to work — signing in, security, remembering your consent. They cannot be turned off.'
|
|
39
|
+
},
|
|
40
|
+
functional: {
|
|
41
|
+
title: 'Functional',
|
|
42
|
+
desc: 'Remember your preferences, such as language or chat, so you do not set them up again.'
|
|
43
|
+
},
|
|
44
|
+
analytics: {
|
|
45
|
+
title: 'Analytics',
|
|
46
|
+
desc: 'Help us see which pages people use, so we can fix what is confusing. Numbers only, no names.'
|
|
47
|
+
},
|
|
48
|
+
marketing: {
|
|
49
|
+
title: 'Marketing',
|
|
50
|
+
desc: 'Let us show you ads on other sites and measure whether they were any use.'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
ru: {
|
|
55
|
+
bannerTitle: 'Мы используем cookie',
|
|
56
|
+
bannerText: 'Необходимые cookie нужны, чтобы сайт работал. Всё остальное — аналитика, маркетинг, дополнительные удобства — включается только с вашего согласия. Решение можно изменить в любой момент.',
|
|
57
|
+
more: 'Подробнее',
|
|
58
|
+
acceptAll: 'Принять всё',
|
|
59
|
+
rejectAll: 'Отклонить всё',
|
|
60
|
+
customize: 'Настроить',
|
|
61
|
+
bannerLabel: 'Согласие на cookie',
|
|
62
|
+
panelTitle: 'Настройки cookie',
|
|
63
|
+
panelIntro: 'Выберите, какие cookie вы разрешаете. Ничего необязательного не включено, пока вы сами это не сделаете.',
|
|
64
|
+
save: 'Сохранить выбор',
|
|
65
|
+
close: 'Закрыть',
|
|
66
|
+
alwaysOn: 'всегда активны',
|
|
67
|
+
cookiesIn: 'Cookie в этой группе',
|
|
68
|
+
noCookies: 'Для этой группы cookie не заявлены.',
|
|
69
|
+
colName: 'Имя',
|
|
70
|
+
colVendor: 'Поставщик',
|
|
71
|
+
colPurpose: 'Цель',
|
|
72
|
+
colExpiry: 'Срок',
|
|
73
|
+
floating: 'Настройки cookie',
|
|
74
|
+
poweredBy: 'Работает на ConsentKit',
|
|
75
|
+
cat: {
|
|
76
|
+
necessary: {
|
|
77
|
+
title: 'Необходимые',
|
|
78
|
+
desc: 'Без них сайт не работает: вход, безопасность, память о вашем выборе. Отключить нельзя.'
|
|
79
|
+
},
|
|
80
|
+
functional: {
|
|
81
|
+
title: 'Функциональные',
|
|
82
|
+
desc: 'Запоминают ваши настройки — например язык или чат, — чтобы вы не задавали их заново.'
|
|
83
|
+
},
|
|
84
|
+
analytics: {
|
|
85
|
+
title: 'Аналитика',
|
|
86
|
+
desc: 'Показывают нам, какими страницами вы пользуетесь, чтобы мы исправили неудобное. Только цифры, без имён.'
|
|
87
|
+
},
|
|
88
|
+
marketing: {
|
|
89
|
+
title: 'Маркетинг',
|
|
90
|
+
desc: 'Позволяют показывать вам рекламу на других сайтах и понимать, была ли от неё польза.'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Keys an external locale may supply. Kept in sync with DICT.en by shape.
|
|
97
|
+
// A key listed here but missing from an external locale falls back to DICT.en
|
|
98
|
+
// in buildStrings() — that is what keeps the 32 locales in ck-locales.js whole
|
|
99
|
+
// when a new key like poweredBy is added here and not (yet) translated there.
|
|
100
|
+
// Adding a key to DICT without adding it here would leave T.<key> undefined
|
|
101
|
+
// for every external locale and render the literal string "undefined".
|
|
102
|
+
var STR_KEYS = [
|
|
103
|
+
'bannerTitle', 'bannerText', 'more', 'acceptAll', 'rejectAll', 'customize',
|
|
104
|
+
'bannerLabel', 'panelTitle', 'panelIntro', 'save', 'close', 'alwaysOn',
|
|
105
|
+
'cookiesIn', 'noCookies', 'colName', 'colVendor', 'colPurpose', 'colExpiry', 'floating',
|
|
106
|
+
'poweredBy'
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// builtin(en,ru) <- window.__ckLocales, read at render time so the locales
|
|
110
|
+
// file may load in any order relative to this one.
|
|
111
|
+
function localeTable() {
|
|
112
|
+
var table = {};
|
|
113
|
+
var k;
|
|
114
|
+
for (k in DICT) {
|
|
115
|
+
if (Object.prototype.hasOwnProperty.call(DICT, k)) table[k.toLowerCase()] = DICT[k];
|
|
116
|
+
}
|
|
117
|
+
var ext = (typeof window !== 'undefined') && window.__ckLocales;
|
|
118
|
+
if (!ext || typeof ext !== 'object') return table;
|
|
119
|
+
for (k in ext) {
|
|
120
|
+
if (!Object.prototype.hasOwnProperty.call(ext, k)) continue;
|
|
121
|
+
var v = ext[k];
|
|
122
|
+
if (v && typeof v === 'object') table[String(k).toLowerCase()] = v;
|
|
123
|
+
}
|
|
124
|
+
return table;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// exact lowercase match -> first two letters (pt-BR -> pt) -> en
|
|
128
|
+
function resolveLang(cfgLang, table) {
|
|
129
|
+
var raw = cfgLang;
|
|
130
|
+
if (!raw || raw === 'auto') {
|
|
131
|
+
raw = (typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || 'en';
|
|
132
|
+
}
|
|
133
|
+
var code = String(raw).toLowerCase();
|
|
134
|
+
if (table[code]) return code;
|
|
135
|
+
var short = code.slice(0, 2);
|
|
136
|
+
if (table[short]) return short;
|
|
137
|
+
return 'en';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Deep two-level fill from en: a partial locale must never yield undefined,
|
|
141
|
+
// which would render the literal string "undefined".
|
|
142
|
+
function buildStrings(lang, table) {
|
|
143
|
+
var src = table[lang] || {};
|
|
144
|
+
var base = DICT.en;
|
|
145
|
+
var out = {};
|
|
146
|
+
var i, c;
|
|
147
|
+
for (i = 0; i < STR_KEYS.length; i++) {
|
|
148
|
+
var k = STR_KEYS[i];
|
|
149
|
+
out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
|
|
150
|
+
}
|
|
151
|
+
out.cat = {};
|
|
152
|
+
var sc = (src.cat && typeof src.cat === 'object') ? src.cat : {};
|
|
153
|
+
for (i = 0; i < ALL_CATS.length; i++) {
|
|
154
|
+
c = ALL_CATS[i];
|
|
155
|
+
var e = (sc[c] && typeof sc[c] === 'object') ? sc[c] : {};
|
|
156
|
+
out.cat[c] = {
|
|
157
|
+
title: (typeof e.title === 'string' && e.title) ? e.title : base.cat[c].title,
|
|
158
|
+
desc: (typeof e.desc === 'string' && e.desc) ? e.desc : base.cat[c].desc
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* --------------------------------------------------------------- styles */
|
|
165
|
+
|
|
166
|
+
var CSS = [
|
|
167
|
+
':host{all:initial}',
|
|
168
|
+
'*,*::before,*::after{box-sizing:border-box}',
|
|
169
|
+
/* Palette tokens live in a second, generated stylesheet (see buildThemeCss).
|
|
170
|
+
They must NOT be inline host styles: an inline value outbeats every :host
|
|
171
|
+
rule, which would make the dark media query and the forced-mode class dead. */
|
|
172
|
+
':host{',
|
|
173
|
+
'font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;',
|
|
174
|
+
'font-size:15px;line-height:1.5;color:var(--ck-ink);',
|
|
175
|
+
'-webkit-font-smoothing:antialiased}',
|
|
176
|
+
|
|
177
|
+
'.ck-hidden{display:none !important}',
|
|
178
|
+
|
|
179
|
+
'button{font:inherit;color:inherit;margin:0;cursor:pointer}',
|
|
180
|
+
'a{color:var(--ck-accent)}',
|
|
181
|
+
':focus-visible{outline:2px solid var(--ck-accent);outline-offset:2px;border-radius:4px}',
|
|
182
|
+
|
|
183
|
+
/* ---- banner ---- */
|
|
184
|
+
'.ck-scrim{position:fixed;inset:0;background:rgba(16,20,30,.28);z-index:2147483000;pointer-events:none}',
|
|
185
|
+
'.ck-banner{position:fixed;z-index:2147483001;background:var(--ck-bg);color:var(--ck-ink);',
|
|
186
|
+
'border:1px solid var(--ck-line);border-radius:var(--ck-radius);pointer-events:auto}',
|
|
187
|
+
'.ck-banner--bar{left:16px;right:16px;padding:18px 20px;',
|
|
188
|
+
'display:flex;gap:20px;align-items:center;flex-wrap:wrap}',
|
|
189
|
+
'.ck-banner--bar.ck-pos-bottom{bottom:16px}',
|
|
190
|
+
'.ck-banner--bar.ck-pos-top{top:16px}',
|
|
191
|
+
'.ck-banner--modal{top:50%;left:50%;transform:translate(-50%,-50%);',
|
|
192
|
+
'width:min(560px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;padding:24px}',
|
|
193
|
+
|
|
194
|
+
/* box: compact card, corner-anchored, no scrim */
|
|
195
|
+
'.ck-banner--box{width:min(360px,calc(100vw - 32px));max-height:calc(100vh - 32px);',
|
|
196
|
+
'overflow:auto;padding:20px;display:block}',
|
|
197
|
+
'.ck-banner--box.ck-pos-bottom-right{bottom:16px;right:16px}',
|
|
198
|
+
'.ck-banner--box.ck-pos-bottom-left{bottom:16px;left:16px}',
|
|
199
|
+
/* Vertical layouts: the copy ends with the "learn more" link, so the gap
|
|
200
|
+
below it has to clear a text baseline, not just a block edge — 16px
|
|
201
|
+
reads as attached to the buttons. */
|
|
202
|
+
'.ck-banner.ck-banner--box p,.ck-banner.ck-banner--modal p{margin-bottom:22px}',
|
|
203
|
+
/* both filled buttons share one equal row; outline spans the width below */
|
|
204
|
+
'.ck-banner--box .ck-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}',
|
|
205
|
+
'.ck-banner--box .ck-btn{min-width:0;width:100%}',
|
|
206
|
+
'.ck-banner--box .ck-btn--outline{grid-column:1 / -1}',
|
|
207
|
+
'.ck-banner__body{flex:1 1 320px;min-width:0}',
|
|
208
|
+
'.ck-banner h2{margin:0 0 6px;font-size:17px;font-weight:600;letter-spacing:-.01em}',
|
|
209
|
+
'.ck-banner p{margin:0;color:var(--ck-muted);font-size:14px}',
|
|
210
|
+
'.ck-banner--modal p{margin-bottom:20px}',
|
|
211
|
+
'.ck-banner__more{white-space:nowrap}',
|
|
212
|
+
|
|
213
|
+
/* ---- equal-weight action row ---- */
|
|
214
|
+
'.ck-actions{display:flex;gap:10px;flex-wrap:wrap;flex:0 1 auto}',
|
|
215
|
+
'.ck-banner--modal .ck-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}',
|
|
216
|
+
'.ck-btn{display:inline-flex;align-items:center;justify-content:center;text-align:center;',
|
|
217
|
+
'min-width:150px;min-height:44px;padding:11px 18px;font-size:14px;font-weight:600;line-height:1.2;',
|
|
218
|
+
'border-radius:var(--ck-radius);border:1px solid transparent;background:transparent;flex:1 1 auto}',
|
|
219
|
+
'.ck-btn--filled{background:var(--ck-accent);border-color:var(--ck-accent);color:var(--ck-on-accent)}',
|
|
220
|
+
'.ck-btn--outline{background:transparent;border-color:var(--ck-accent);color:var(--ck-accent)}',
|
|
221
|
+
'.ck-btn--ghost{min-width:0;border-color:var(--ck-line);color:var(--ck-ink);font-weight:500}',
|
|
222
|
+
|
|
223
|
+
/* ---- panel ---- */
|
|
224
|
+
'.ck-panel-scrim{position:fixed;inset:0;background:rgba(16,20,30,.34);z-index:2147483002;pointer-events:none}',
|
|
225
|
+
'.ck-panel{position:fixed;z-index:2147483003;top:50%;left:50%;transform:translate(-50%,-50%);',
|
|
226
|
+
'width:min(620px,calc(100vw - 32px));max-height:calc(100vh - 48px);',
|
|
227
|
+
'display:flex;flex-direction:column;background:var(--ck-bg);color:var(--ck-ink);',
|
|
228
|
+
'border:1px solid var(--ck-line);border-radius:var(--ck-radius);overflow:hidden}',
|
|
229
|
+
'.ck-panel__head{display:flex;align-items:flex-start;gap:16px;padding:22px 24px 14px;',
|
|
230
|
+
'border-bottom:1px solid var(--ck-line)}',
|
|
231
|
+
/* min-width:0 so a wide logo shrinks rather than shoving the close button
|
|
232
|
+
off. Scoped to :has(.ck-brand) — applying it unconditionally changes the
|
|
233
|
+
header block's flex sizing (480px -> 518px) on unbranded panels too, which
|
|
234
|
+
would break byte-for-byte backward compatibility. Browsers without :has()
|
|
235
|
+
simply keep today's sizing; the logo is width-capped at 160px regardless,
|
|
236
|
+
so the close button still has room. */
|
|
237
|
+
'.ck-panel__head>div:first-child:has(.ck-brand){flex:1 1 auto;min-width:0}',
|
|
238
|
+
'.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
|
|
239
|
+
'.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
|
|
240
|
+
'.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius);border:1px solid var(--ck-line);',
|
|
241
|
+
'background:transparent;display:inline-flex;align-items:center;justify-content:center;color:var(--ck-muted)}',
|
|
242
|
+
'.ck-panel__body{overflow:auto;padding:6px 24px 10px;-webkit-overflow-scrolling:touch}',
|
|
243
|
+
'.ck-panel__foot{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px;',
|
|
244
|
+
'border-top:1px solid var(--ck-line);background:var(--ck-soft)}',
|
|
245
|
+
'.ck-panel__foot .ck-btn{flex:1 1 150px}',
|
|
246
|
+
|
|
247
|
+
/* ---- category row ---- */
|
|
248
|
+
'.ck-cat{padding:16px 0;border-bottom:1px solid var(--ck-line)}',
|
|
249
|
+
'.ck-cat:last-child{border-bottom:0}',
|
|
250
|
+
'.ck-cat__top{display:flex;gap:16px;align-items:flex-start}',
|
|
251
|
+
'.ck-cat__txt{flex:1 1 auto;min-width:0}',
|
|
252
|
+
'.ck-cat__name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:15px;font-weight:600}',
|
|
253
|
+
'.ck-cat__badge{font-size:12px;font-weight:500;color:var(--ck-muted);',
|
|
254
|
+
'border:1px solid var(--ck-line);border-radius:999px;padding:1px 8px}',
|
|
255
|
+
'.ck-cat__desc{margin:4px 0 0;font-size:13.5px;color:var(--ck-muted)}',
|
|
256
|
+
|
|
257
|
+
/* ---- switch ---- */
|
|
258
|
+
'.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
|
|
259
|
+
'border:1px solid var(--ck-line);background:var(--ck-soft);position:relative}',
|
|
260
|
+
'.ck-switch::after{content:"";position:absolute;top:2px;left:2px;width:21px;height:21px;',
|
|
261
|
+
'border-radius:50%;background:var(--ck-bg);border:1px solid var(--ck-line)}',
|
|
262
|
+
'.ck-switch[aria-checked="true"]{background:var(--ck-accent);border-color:var(--ck-accent)}',
|
|
263
|
+
'.ck-switch[aria-checked="true"]::after{left:auto;right:2px;border-color:transparent}',
|
|
264
|
+
'.ck-switch[disabled]{cursor:not-allowed;opacity:.55}',
|
|
265
|
+
|
|
266
|
+
/* ---- cookie table ---- */
|
|
267
|
+
'.ck-det{margin-top:12px}',
|
|
268
|
+
'.ck-det>summary{cursor:pointer;font-size:13px;color:var(--ck-accent);',
|
|
269
|
+
'list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}',
|
|
270
|
+
'.ck-det>summary::-webkit-details-marker{display:none}',
|
|
271
|
+
'.ck-det>summary::before{content:"";width:0;height:0;border:4px solid transparent;',
|
|
272
|
+
'border-left-color:currentColor;border-right:0}',
|
|
273
|
+
'.ck-det[open]>summary::before{transform:rotate(90deg)}',
|
|
274
|
+
'.ck-tablewrap{margin-top:8px;overflow-x:auto;border:1px solid var(--ck-line);border-radius:var(--ck-radius)}',
|
|
275
|
+
'table{border-collapse:collapse;width:100%;font-size:13px;min-width:420px}',
|
|
276
|
+
'th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ck-line);vertical-align:top}',
|
|
277
|
+
'thead th{background:var(--ck-soft);font-weight:600;font-size:12px;color:var(--ck-muted);white-space:nowrap}',
|
|
278
|
+
'tbody tr:last-child td{border-bottom:0}',
|
|
279
|
+
'td.ck-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
|
|
280
|
+
'.ck-empty{margin:8px 0 0;font-size:13px;color:var(--ck-muted)}',
|
|
281
|
+
|
|
282
|
+
/* ---- branding: logo + powered-by ----
|
|
283
|
+
The logo sits inline with the title in a flex row. That was chosen over a
|
|
284
|
+
separate band above the heading because .ck-banner--bar is a single
|
|
285
|
+
vertically-centred flex row: a stacked logo adds a height band there and
|
|
286
|
+
nowhere else, so bar/box/modal would drift apart. Inline keeps one rule
|
|
287
|
+
for all three layouts and leaves existing margins untouched — .ck-brand
|
|
288
|
+
carries the whole gap, h2 keeps its own margin. */
|
|
289
|
+
'.ck-brand{display:flex;align-items:center;gap:10px;margin:0 0 8px}',
|
|
290
|
+
/* Attribution foot of the banner: logo + credit on one muted line below
|
|
291
|
+
the buttons. flex-basis 100% keeps it on its own row in the bar layout,
|
|
292
|
+
where the actions sit beside the text.
|
|
293
|
+
|
|
294
|
+
The mark is desaturated here rather than shipped as a second grey asset:
|
|
295
|
+
an agency logo in full brand colour reads as a second call to action
|
|
296
|
+
competing with the consent buttons. grayscale() flattens the hue and the
|
|
297
|
+
opacity lifts it off pure black, so it sits at signature weight in both
|
|
298
|
+
themes without the integrator preparing anything. */
|
|
299
|
+
'.ck-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
300
|
+
'margin:14px 0 0}',
|
|
301
|
+
'.ck-foot .ck-brand__logo,.ck-foot .ck-brand__logo svg{',
|
|
302
|
+
'filter:grayscale(1);opacity:.55}',
|
|
303
|
+
'.ck-foot .ck-brand__link:hover .ck-brand__logo,',
|
|
304
|
+
'.ck-foot .ck-brand__link:focus-visible .ck-brand__logo{opacity:.8}',
|
|
305
|
+
'.ck-foot .ck-brand{margin:0}',
|
|
306
|
+
/* Beats the flex:1 1 100% the standalone .ck-powered carries (it needs a
|
|
307
|
+
full row of its own when there is no logo beside it). */
|
|
308
|
+
'.ck-foot p.ck-powered,.ck-banner .ck-foot p.ck-powered{margin:0;flex:0 1 auto}',
|
|
309
|
+
/* In the panel the foot shares a flex row with the action buttons, so it
|
|
310
|
+
claims a row of its own below them. */
|
|
311
|
+
'.ck-panel__foot .ck-foot{flex:1 1 100%;margin:2px 0 0}',
|
|
312
|
+
'.ck-brand__logo{display:block;width:auto;max-width:160px;height:var(--ck-logo-h,24px);',
|
|
313
|
+
'flex:none;object-fit:contain}',
|
|
314
|
+
'.ck-brand__logo svg{display:block;width:auto;height:100%;max-width:160px}',
|
|
315
|
+
'.ck-brand a.ck-brand__link{display:inline-flex;align-items:center;text-decoration:none;flex:none}',
|
|
316
|
+
/* Dark-variant swap is CSS-driven, mirroring buildThemeCss()'s cascade
|
|
317
|
+
exactly (same three selectors, same :not(.ck-mode-light) guard). Reading
|
|
318
|
+
the theme in JS would desync in auto mode and would not follow a live
|
|
319
|
+
system theme flip. */
|
|
320
|
+
'.ck-brand__dark{display:none}',
|
|
321
|
+
'.ck-brand__has-dark .ck-brand__light{display:block}',
|
|
322
|
+
|
|
323
|
+
/* ---- powered-by ----
|
|
324
|
+
Deliberately quiet: muted colour, 12px, normal weight, and it comes after
|
|
325
|
+
the action row in DOM order. It must not compete with the consent buttons. */
|
|
326
|
+
/* .ck-banner p sets font-size:14px at equal specificity and appears later in
|
|
327
|
+
this sheet, so it would win over a bare .ck-powered. Qualifying the
|
|
328
|
+
selector keeps the attribution smaller than the button text (14px) without
|
|
329
|
+
reaching for !important. */
|
|
330
|
+
'.ck-powered,.ck-banner p.ck-powered{margin:12px 0 0;font-size:12px;line-height:1.4;',
|
|
331
|
+
'color:var(--ck-muted);flex:1 1 100%;font-weight:400}',
|
|
332
|
+
'.ck-powered a{color:var(--ck-muted);text-decoration:underline}',
|
|
333
|
+
'.ck-panel__foot .ck-powered{margin:0;align-self:center}',
|
|
334
|
+
|
|
335
|
+
/* ---- floating button ---- */
|
|
336
|
+
'.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
|
|
337
|
+
'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-accent);',
|
|
338
|
+
'display:inline-flex;align-items:center;justify-content:center;padding:0}',
|
|
339
|
+
'.ck-fab svg{width:24px;height:24px;display:block}',
|
|
340
|
+
|
|
341
|
+
'@media (max-width:560px){',
|
|
342
|
+
'.ck-banner--bar{left:8px;right:8px;bottom:8px;padding:16px}',
|
|
343
|
+
'.ck-actions{width:100%}.ck-btn{min-width:0;flex:1 1 100%}',
|
|
344
|
+
'.ck-banner.ck-banner--bar p{margin-bottom:22px}',
|
|
345
|
+
/* Narrow bar stacks into a column, so the foot — which lives at the end of
|
|
346
|
+
the text block for the wide side-by-side layout — would sit between the
|
|
347
|
+
question and the buttons answering it. Lift it out of the text block and
|
|
348
|
+
order it last. */
|
|
349
|
+
'.ck-banner--bar{flex-direction:column;align-items:stretch}',
|
|
350
|
+
'.ck-banner--bar .ck-banner__body{display:contents}',
|
|
351
|
+
'.ck-banner--bar .ck-banner__body>*{order:1}',
|
|
352
|
+
'.ck-banner--bar .ck-actions{order:2}',
|
|
353
|
+
'.ck-banner--bar .ck-foot{order:3;margin-top:14px}}',
|
|
354
|
+
|
|
355
|
+
'@media (prefers-reduced-motion: no-preference){',
|
|
356
|
+
'.ck-btn,.ck-x,.ck-fab,.ck-switch,.ck-switch::after{transition:background-color .16s ease,border-color .16s ease,color .16s ease,left .16s ease,right .16s ease}}'
|
|
357
|
+
].join('\n');
|
|
358
|
+
|
|
359
|
+
var COOKIE_ICON =
|
|
360
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" ' +
|
|
361
|
+
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
|
|
362
|
+
'<path d="M21 12a9 9 0 1 1-9-9 3.4 3.4 0 0 0 4.2 4.2A3.4 3.4 0 0 0 21 12Z"/>' +
|
|
363
|
+
'<circle cx="9" cy="10" r="1"/><circle cx="14.5" cy="15" r="1"/><circle cx="8.5" cy="15.5" r="1"/>' +
|
|
364
|
+
'</svg>';
|
|
365
|
+
|
|
366
|
+
/* --------------------------------------------------------------- helpers */
|
|
367
|
+
|
|
368
|
+
function el(tag, cls, text) {
|
|
369
|
+
var n = document.createElement(tag);
|
|
370
|
+
if (cls) n.className = cls;
|
|
371
|
+
if (text != null) n.textContent = text;
|
|
372
|
+
return n;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function api() {
|
|
376
|
+
return (typeof window !== 'undefined' && window.ConsentKit) || null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* -------------------------------------------------------------- branding */
|
|
380
|
+
|
|
381
|
+
/* Restraint is the design rule here, not a matter of taste.
|
|
382
|
+
|
|
383
|
+
A consent banner is shown to every visitor of the site that installs it,
|
|
384
|
+
and it asks them a legal question. An agency logo, agency colours and an
|
|
385
|
+
attribution line all at once make it read as the agency's dialogue rather
|
|
386
|
+
than the site's own — visitors trust it less, and the banner competes with
|
|
387
|
+
the page it sits on.
|
|
388
|
+
|
|
389
|
+
So: everything in `branding` is off unless asked for, and the recommended
|
|
390
|
+
shape is one small logo (16–20px) OR one attribution line — with
|
|
391
|
+
theme.accent left matching the HOST SITE, never the agency's colour.
|
|
392
|
+
Nothing here may outweigh the consent buttons. */
|
|
393
|
+
|
|
394
|
+
/* SVG sanitiser.
|
|
395
|
+
|
|
396
|
+
branding.logo may be a raw SVG string coming from a server-rendered config
|
|
397
|
+
or a WordPress admin field. That is untrusted input, so it never reaches
|
|
398
|
+
innerHTML: `<svg onload=...>` executes on insertion, and so do SMIL
|
|
399
|
+
`<animate onbegin=...>` and `<foreignObject><img onerror=...>`.
|
|
400
|
+
|
|
401
|
+
Approach chosen: parse inert, then REBUILD rather than strip-and-adopt.
|
|
402
|
+
DOMParser with 'image/svg+xml' yields a detached, non-live document where
|
|
403
|
+
nothing runs. We then walk that tree and construct a brand-new tree with
|
|
404
|
+
createElementNS, copying across only allowlisted tags and attributes.
|
|
405
|
+
|
|
406
|
+
Rebuilding is what makes this safe rather than merely careful. The rejected
|
|
407
|
+
alternative — importNode/appendChild the parsed tree after deleting bad
|
|
408
|
+
attributes — arms every inline handler at the moment of adoption, so a
|
|
409
|
+
single missed attribute name is live code. Here an attribute we do not
|
|
410
|
+
recognise is simply never written, so the failure mode is a missing
|
|
411
|
+
decoration, not script execution. Allowlists (closed) beat blocklists
|
|
412
|
+
(open-ended) for the same reason.
|
|
413
|
+
|
|
414
|
+
Deliberately excluded, each for a concrete reason:
|
|
415
|
+
script - obvious
|
|
416
|
+
foreignObject - escape hatch back into full HTML
|
|
417
|
+
use, image - can reference/fetch external documents
|
|
418
|
+
a - javascript: navigation inside the logo
|
|
419
|
+
style - CSS escapes, and it would leak out of the
|
|
420
|
+
logo into our own shadow-root styling
|
|
421
|
+
animate/set/animateTransform - SMIL takes an attributeName and can drive
|
|
422
|
+
arbitrary attributes, plus on* timing events
|
|
423
|
+
|
|
424
|
+
Anything unexpected bails to null (no logo) rather than partially rendering. */
|
|
425
|
+
|
|
426
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
427
|
+
|
|
428
|
+
var SVG_TAGS = {
|
|
429
|
+
svg: 1, g: 1, path: 1, circle: 1, ellipse: 1, rect: 1, line: 1,
|
|
430
|
+
polyline: 1, polygon: 1, defs: 1, title: 1, desc: 1,
|
|
431
|
+
lineargradient: 1, radialgradient: 1, stop: 1, clippath: 1, mask: 1
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Presentation/geometry only. No href/xlink:href in any form, no on* events.
|
|
435
|
+
var SVG_ATTRS = {
|
|
436
|
+
viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio',
|
|
437
|
+
xmlns: 'xmlns', version: 'version',
|
|
438
|
+
d: 'd', fill: 'fill', 'fill-rule': 'fill-rule', 'fill-opacity': 'fill-opacity',
|
|
439
|
+
'clip-rule': 'clip-rule', 'clip-path': 'clip-path', mask: 'mask',
|
|
440
|
+
stroke: 'stroke', 'stroke-width': 'stroke-width', 'stroke-linecap': 'stroke-linecap',
|
|
441
|
+
'stroke-linejoin': 'stroke-linejoin', 'stroke-dasharray': 'stroke-dasharray',
|
|
442
|
+
'stroke-dashoffset': 'stroke-dashoffset', 'stroke-opacity': 'stroke-opacity',
|
|
443
|
+
'stroke-miterlimit': 'stroke-miterlimit',
|
|
444
|
+
opacity: 'opacity', transform: 'transform',
|
|
445
|
+
x: 'x', y: 'y', x1: 'x1', y1: 'y1', x2: 'x2', y2: 'y2',
|
|
446
|
+
cx: 'cx', cy: 'cy', r: 'r', rx: 'rx', ry: 'ry',
|
|
447
|
+
width: 'width', height: 'height', points: 'points',
|
|
448
|
+
offset: 'offset', 'stop-color': 'stop-color', 'stop-opacity': 'stop-opacity',
|
|
449
|
+
gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform',
|
|
450
|
+
spreadmethod: 'spreadMethod', clippathunits: 'clipPathUnits',
|
|
451
|
+
maskunits: 'maskUnits', maskcontentunits: 'maskContentUnits',
|
|
452
|
+
id: 'id', 'class': 'class'
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/* Every sanitised logo gets a unique id namespace.
|
|
456
|
+
|
|
457
|
+
buildBrandLogo() runs twice per mount (banner + panel head), and doubles
|
|
458
|
+
again when logoDark is set — so one shadow root can hold four copies of the
|
|
459
|
+
same asset. A gradient/clipPath/mask id like "g" would then appear four
|
|
460
|
+
times, and url(#g) resolves to the FIRST match in the tree: the dark logo
|
|
461
|
+
would silently paint with the light logo's gradient stops. Prefixing every
|
|
462
|
+
id per instance, and rewriting the url(#…) references in the same pass,
|
|
463
|
+
keeps each copy self-contained. */
|
|
464
|
+
var svgSeq = 0;
|
|
465
|
+
|
|
466
|
+
// url(#localRef) and plain values only — no url(http…), no javascript:.
|
|
467
|
+
// `prefix` namespaces id definitions and their url(#…) references together.
|
|
468
|
+
function safeAttrValue(name, value, prefix) {
|
|
469
|
+
var v = String(value == null ? '' : value);
|
|
470
|
+
// Strip nothing; reject outright. Control chars are how javascript: is hidden.
|
|
471
|
+
var probe = v.replace(/[\u0000-\u0020\u007f-\u00a0]/g, '').toLowerCase();
|
|
472
|
+
if (probe.indexOf('javascript:') !== -1) return null;
|
|
473
|
+
if (probe.indexOf('data:text') !== -1) return null;
|
|
474
|
+
if (probe.indexOf('&#') !== -1) return null;
|
|
475
|
+
// Any url() must be a same-document fragment reference.
|
|
476
|
+
if (probe.indexOf('url(') !== -1 && !/^url\(#[a-z0-9_.:-]+\)$/i.test(probe)) return null;
|
|
477
|
+
if (name === 'id') {
|
|
478
|
+
if (!/^[a-zA-Z][\w.:-]*$/.test(v)) return null;
|
|
479
|
+
return prefix + v;
|
|
480
|
+
}
|
|
481
|
+
// Rewrite a reference so it points at THIS instance's namespaced definition.
|
|
482
|
+
var m = /^url\(#([\w.:-]+)\)$/.exec(v);
|
|
483
|
+
if (m) return 'url(#' + prefix + m[1] + ')';
|
|
484
|
+
return v;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function rebuildSvgNode(src, out, depth, prefix) {
|
|
488
|
+
if (depth > 24) return false; // pathological nesting
|
|
489
|
+
var kids = src.childNodes;
|
|
490
|
+
for (var i = 0; i < kids.length; i++) {
|
|
491
|
+
var n = kids[i];
|
|
492
|
+
if (n.nodeType === 3) { // text (only inside title/desc)
|
|
493
|
+
var pt = out.nodeName.toLowerCase();
|
|
494
|
+
if (pt === 'title' || pt === 'desc') out.appendChild(document.createTextNode(n.nodeValue));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (n.nodeType !== 1) continue; // drop comments, CDATA, PIs
|
|
498
|
+
var tag = String(n.nodeName || '').toLowerCase();
|
|
499
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, tag)) return false; // bail, don't skip
|
|
500
|
+
var fresh = document.createElementNS(SVG_NS, n.nodeName);
|
|
501
|
+
var attrs = n.attributes || [];
|
|
502
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
503
|
+
var an = String(attrs[a].name || '').toLowerCase();
|
|
504
|
+
if (/^on/i.test(an)) return false; // event handler present -> reject whole logo
|
|
505
|
+
if (an === 'href' || an === 'xlink:href' || an.indexOf('xlink') === 0) return false;
|
|
506
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue; // unknown -> just omit
|
|
507
|
+
var val = safeAttrValue(an, attrs[a].value, prefix);
|
|
508
|
+
if (val === null) continue;
|
|
509
|
+
try { fresh.setAttribute(SVG_ATTRS[an], val); } catch (e) { /* ignore */ }
|
|
510
|
+
}
|
|
511
|
+
if (!rebuildSvgNode(n, fresh, depth + 1, prefix)) return false;
|
|
512
|
+
out.appendChild(fresh);
|
|
513
|
+
}
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Raw SVG string -> freshly built, safe <svg> element, or null.
|
|
518
|
+
function sanitizeSvg(markup) {
|
|
519
|
+
var s = str(markup);
|
|
520
|
+
if (!s || s.length > 512 * 1024) return null;
|
|
521
|
+
if (!/^\s*<svg[\s>]/i.test(s)) return null; // must be an SVG root
|
|
522
|
+
var doc;
|
|
523
|
+
try {
|
|
524
|
+
doc = new DOMParser().parseFromString(s, 'image/svg+xml');
|
|
525
|
+
} catch (e) { return null; }
|
|
526
|
+
if (!doc) return null;
|
|
527
|
+
if (doc.getElementsByTagName('parsererror').length) return null;
|
|
528
|
+
var srcRoot = doc.documentElement;
|
|
529
|
+
if (!srcRoot || String(srcRoot.nodeName).toLowerCase() !== 'svg') return null;
|
|
530
|
+
|
|
531
|
+
// Unique per sanitised instance, so four copies of one asset never collide.
|
|
532
|
+
var prefix = 'ck' + (++svgSeq) + '-';
|
|
533
|
+
|
|
534
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
535
|
+
var ra = srcRoot.attributes || [];
|
|
536
|
+
for (var i = 0; i < ra.length; i++) {
|
|
537
|
+
var an = String(ra[i].name || '').toLowerCase();
|
|
538
|
+
if (/^on/i.test(an)) return null;
|
|
539
|
+
if (an.indexOf('xlink') === 0 || an === 'href') return null;
|
|
540
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue;
|
|
541
|
+
var val = safeAttrValue(an, ra[i].value, prefix);
|
|
542
|
+
if (val === null) continue;
|
|
543
|
+
try { svg.setAttribute(SVG_ATTRS[an], val); } catch (e) {}
|
|
544
|
+
}
|
|
545
|
+
if (!rebuildSvgNode(srcRoot, svg, 0, prefix)) return null;
|
|
546
|
+
return svg;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* Image-source logos.
|
|
550
|
+
Only http(s) and image data: URIs. data:text/html is a navigation/XSS
|
|
551
|
+
vector via <img>-adjacent contexts, and any other scheme is rejected.
|
|
552
|
+
|
|
553
|
+
NOTE FOR INTEGRATORS: an https:// logo is an external network request that
|
|
554
|
+
fires BEFORE the visitor has consented to anything. It leaks IP, User-Agent
|
|
555
|
+
and Referer to whoever hosts the file. ConsentKit therefore recommends an
|
|
556
|
+
inline SVG string or a data: URI, both of which are entirely local. An
|
|
557
|
+
external URL still works — it is the integrator's call, made knowingly —
|
|
558
|
+
and we send referrerpolicy=no-referrer to reduce what leaks. */
|
|
559
|
+
function safeImgSrc(value) {
|
|
560
|
+
var v = str(value);
|
|
561
|
+
if (!v) return null;
|
|
562
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
563
|
+
if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif|avif)[;,]/i.test(v)) return v;
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Default 18px and a 32px ceiling: the logo is a signature, not a header.
|
|
568
|
+
// Anything taller starts competing with the banner title.
|
|
569
|
+
function clampLogoHeight(v) {
|
|
570
|
+
var n = (typeof v === 'number') ? v : parseFloat(v);
|
|
571
|
+
if (!isFinite(n)) return 18;
|
|
572
|
+
if (n < 14) return 14;
|
|
573
|
+
if (n > 32) return 32;
|
|
574
|
+
return Math.round(n);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Only http(s) links are made clickable; javascript:/data: never become hrefs.
|
|
578
|
+
function safeLinkUrl(value) {
|
|
579
|
+
var v = str(value);
|
|
580
|
+
if (!v) return null;
|
|
581
|
+
return /^https?:\/\//i.test(v) ? v : null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function brandingCfg(cfg) {
|
|
585
|
+
var b = cfg && cfg.branding;
|
|
586
|
+
return (b && typeof b === 'object' && !Array.isArray(b)) ? b : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// One logo node (inline SVG or <img>), already sanitised. null when unusable.
|
|
590
|
+
function buildLogoNode(source, alt, decorative) {
|
|
591
|
+
if (!source) return null;
|
|
592
|
+
var node = null;
|
|
593
|
+
var s = str(source);
|
|
594
|
+
if (!s) return null;
|
|
595
|
+
|
|
596
|
+
if (/^\s*</.test(s)) {
|
|
597
|
+
node = sanitizeSvg(s); // raw markup -> rebuilt SVG
|
|
598
|
+
if (node) node.classList.add('ck-brand__logo');
|
|
599
|
+
} else {
|
|
600
|
+
var src = safeImgSrc(s);
|
|
601
|
+
if (!src) return null;
|
|
602
|
+
node = el('img', 'ck-brand__logo');
|
|
603
|
+
node.setAttribute('referrerpolicy', 'no-referrer');
|
|
604
|
+
node.setAttribute('decoding', 'async');
|
|
605
|
+
node.src = src;
|
|
606
|
+
node.alt = decorative ? '' : (alt || '');
|
|
607
|
+
}
|
|
608
|
+
// The SVG carries no accessible name of its own; the wrapper supplies one
|
|
609
|
+
// (or hides it, when a sibling already names the logo).
|
|
610
|
+
if (node && node.nodeName.toLowerCase() === 'svg') {
|
|
611
|
+
node.setAttribute('aria-hidden', 'true');
|
|
612
|
+
node.setAttribute('focusable', 'false');
|
|
613
|
+
}
|
|
614
|
+
return node;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* Logo block for a banner/panel header.
|
|
618
|
+
|
|
619
|
+
Dark theme: branding.logoDark, when supplied, is rendered as a second node
|
|
620
|
+
and swapped purely in CSS. When it is absent the single main logo shows in
|
|
621
|
+
both themes — which is why the shipped ECOM Consult asset (wordmark
|
|
622
|
+
fill="white", built for dark backgrounds) belongs in logoDark, with a
|
|
623
|
+
dark-ink variant in logo. An <img>/data: logo cannot be recoloured by our
|
|
624
|
+
CSS at all, so two assets are the only route there; an inline SVG could in
|
|
625
|
+
principle inherit currentColor, but only if the asset is authored that way. */
|
|
626
|
+
function buildBrandLogo(cfg) {
|
|
627
|
+
var b = brandingCfg(cfg);
|
|
628
|
+
if (!b) return null;
|
|
629
|
+
|
|
630
|
+
var alt = str(b.logoAlt) || '';
|
|
631
|
+
var main = buildLogoNode(b.logo, alt, false);
|
|
632
|
+
if (!main) return null; // no valid logo -> render nothing
|
|
633
|
+
|
|
634
|
+
var dark = buildLogoNode(b.logoDark, alt, false);
|
|
635
|
+
|
|
636
|
+
var wrap = el('div', 'ck-brand');
|
|
637
|
+
if (dark) {
|
|
638
|
+
wrap.classList.add('ck-brand__has-dark');
|
|
639
|
+
main.classList.add('ck-brand__light');
|
|
640
|
+
dark.classList.add('ck-brand__dark');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
var link = safeLinkUrl(b.logoUrl);
|
|
644
|
+
var host_ = wrap;
|
|
645
|
+
if (link) {
|
|
646
|
+
var a = el('a', 'ck-brand__link');
|
|
647
|
+
a.href = link;
|
|
648
|
+
a.target = '_blank';
|
|
649
|
+
a.rel = 'noopener noreferrer';
|
|
650
|
+
// Links are focusable by nature; the accessible name comes from logoAlt.
|
|
651
|
+
a.setAttribute('aria-label', alt || 'ConsentKit');
|
|
652
|
+
host_ = a;
|
|
653
|
+
wrap.appendChild(a);
|
|
654
|
+
}
|
|
655
|
+
host_.appendChild(main);
|
|
656
|
+
if (dark) host_.appendChild(dark);
|
|
657
|
+
|
|
658
|
+
// A non-linked logo must not be a tab stop. The <svg>/<img> is aria-hidden
|
|
659
|
+
// or alt="", so a visually-hidden-free text alternative is supplied here
|
|
660
|
+
// for the image case only when it is not already announced by the <img> alt.
|
|
661
|
+
if (!link && alt && main.nodeName.toLowerCase() === 'svg') {
|
|
662
|
+
wrap.setAttribute('role', 'img');
|
|
663
|
+
wrap.setAttribute('aria-label', alt);
|
|
664
|
+
}
|
|
665
|
+
return wrap;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
|
|
669
|
+
function buildBrandCss(cfg) {
|
|
670
|
+
var b = brandingCfg(cfg);
|
|
671
|
+
if (!b) return '';
|
|
672
|
+
var h = clampLogoHeight(b.logoHeight);
|
|
673
|
+
var out = [':host{--ck-logo-h:' + h + 'px}'];
|
|
674
|
+
|
|
675
|
+
var theme = (cfg && cfg.theme) || {};
|
|
676
|
+
var mode = theme.mode;
|
|
677
|
+
if (mode !== 'light' && mode !== 'dark') mode = 'auto';
|
|
678
|
+
|
|
679
|
+
// Same cascade shape as buildThemeCss so the logo always agrees with the
|
|
680
|
+
// palette: auto mode follows prefers-color-scheme but a forced .ck-mode-light
|
|
681
|
+
// still wins, and .ck-mode-dark forces the dark asset outright.
|
|
682
|
+
function swap(prefix) {
|
|
683
|
+
return prefix + ' .ck-brand__has-dark .ck-brand__light{display:none}\n' +
|
|
684
|
+
prefix + ' .ck-brand__has-dark .ck-brand__dark{display:block}';
|
|
685
|
+
}
|
|
686
|
+
if (mode === 'auto') {
|
|
687
|
+
out.push('@media (prefers-color-scheme: dark){\n' +
|
|
688
|
+
swap(':host(:not(.ck-mode-light))') + '\n}');
|
|
689
|
+
}
|
|
690
|
+
out.push(swap(':host(.ck-mode-dark)'));
|
|
691
|
+
return out.join('\n');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/* Powered-by line. true -> localised default; object -> caller's text/url.
|
|
695
|
+
Rendered after the actions in DOM order and styled quiet on purpose. */
|
|
696
|
+
function buildPoweredBy(cfg) {
|
|
697
|
+
var b = brandingCfg(cfg);
|
|
698
|
+
if (!b) return null;
|
|
699
|
+
var pb = b.poweredBy;
|
|
700
|
+
if (!pb) return null; // false/undefined -> nothing
|
|
701
|
+
|
|
702
|
+
var text, url = null;
|
|
703
|
+
if (pb === true) {
|
|
704
|
+
text = T.poweredBy; // en fallback guaranteed by STR_KEYS
|
|
705
|
+
} else if (typeof pb === 'object' && !Array.isArray(pb)) {
|
|
706
|
+
text = str(pb.text) || T.poweredBy;
|
|
707
|
+
url = safeLinkUrl(pb.url);
|
|
708
|
+
} else {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
var p = el('p', 'ck-powered');
|
|
713
|
+
if (url) {
|
|
714
|
+
var a = el('a', null, text);
|
|
715
|
+
a.href = url;
|
|
716
|
+
a.target = '_blank';
|
|
717
|
+
a.rel = 'noopener noreferrer';
|
|
718
|
+
p.appendChild(a);
|
|
719
|
+
} else {
|
|
720
|
+
p.appendChild(document.createTextNode(text));
|
|
721
|
+
}
|
|
722
|
+
return p;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function safeState() {
|
|
726
|
+
var ck = api();
|
|
727
|
+
var s = null;
|
|
728
|
+
try {
|
|
729
|
+
if (ck && typeof ck.getState === 'function') s = ck.getState();
|
|
730
|
+
} catch (e) { s = null; }
|
|
731
|
+
if (!s || typeof s !== 'object') s = { decided: false, categories: {} };
|
|
732
|
+
if (!s.categories || typeof s.categories !== 'object') s.categories = {};
|
|
733
|
+
return s;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function safeConfig() {
|
|
737
|
+
var ck = api();
|
|
738
|
+
var c = (ck && ck.config) || {};
|
|
739
|
+
return (c && typeof c === 'object') ? c : {};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ----------------------------------------------------------------- state */
|
|
743
|
+
|
|
744
|
+
var mounted = false;
|
|
745
|
+
var mountedSig = null;
|
|
746
|
+
var host = null, root = null;
|
|
747
|
+
var T = DICT.en;
|
|
748
|
+
var nodes = {}; // banner/panel/fab refs
|
|
749
|
+
var switches = {}; // category -> button
|
|
750
|
+
var panelOpen = false;
|
|
751
|
+
var lastFocus = null;
|
|
752
|
+
|
|
753
|
+
/* ---------------------------------------------------------------- build */
|
|
754
|
+
|
|
755
|
+
function activeOptIn(cfg) {
|
|
756
|
+
var cats = (cfg && cfg.categories) || {};
|
|
757
|
+
var out = [];
|
|
758
|
+
for (var i = 0; i < OPT_IN.length; i++) {
|
|
759
|
+
var k = OPT_IN[i];
|
|
760
|
+
var entry = cats[k];
|
|
761
|
+
// absent -> shown; explicit enabled:false -> hidden
|
|
762
|
+
if (entry && entry.enabled === false) continue;
|
|
763
|
+
out.push(k);
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function cookiesFor(cfg, cat) {
|
|
769
|
+
var list = (cfg && cfg.cookieTable) || [];
|
|
770
|
+
if (!Array.isArray(list)) return [];
|
|
771
|
+
var out = [];
|
|
772
|
+
for (var i = 0; i < list.length; i++) {
|
|
773
|
+
var row = list[i];
|
|
774
|
+
if (row && typeof row === 'object' && String(row.category || '') === cat) out.push(row);
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* ----------------------------------------------------------------- theme */
|
|
780
|
+
|
|
781
|
+
// Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
|
|
782
|
+
var LIGHT_RADIUS = '10px';
|
|
783
|
+
var LIGHT = {
|
|
784
|
+
bg: '#fff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#fff',
|
|
785
|
+
muted: '#5b6478', line: '#dfe3ea', soft: '#f4f6f9'
|
|
786
|
+
};
|
|
787
|
+
var DARK = {
|
|
788
|
+
bg: '#1A202D', ink: '#E6EAF4', accent: '#7B96F0', onAccent: '#12182A',
|
|
789
|
+
muted: '#A6B0C6', line: '#333C4F', soft: '#232B3A'
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
function str(v) {
|
|
793
|
+
return (typeof v === 'string' && v.trim()) ? v.trim() : null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* Config values are interpolated into the TEXT of a generated stylesheet, so
|
|
797
|
+
an unvalidated value can close the declaration and open rules of its own
|
|
798
|
+
("10px;}.ck-btn--filled{display:none" hides "Reject all"). config.theme is
|
|
799
|
+
not trusted input: in standalone mode it comes straight from the embedding
|
|
800
|
+
page or an integrator's admin panel, with no server-side validation
|
|
801
|
+
anywhere in the path. So every value is matched against a strict grammar
|
|
802
|
+
for its kind and silently replaced by the token default when it does not
|
|
803
|
+
fit — a broken colour is a cosmetic bug, an injected rule is a defacement
|
|
804
|
+
and can strip the reject button, which is a consent-validity problem.
|
|
805
|
+
|
|
806
|
+
Sanitising happens at the entry points in buildThemeCss (the eight
|
|
807
|
+
config reads), not in tokenBlock(): by the time values reach derive() and
|
|
808
|
+
color-mix() they are already clean, and the built-in constants and
|
|
809
|
+
generated color-mix() strings must not be re-validated by this grammar. */
|
|
810
|
+
var RE_CSS = {
|
|
811
|
+
// #RGB / #RRGGBB / #RRGGBBAA (and #RGBA), rgb()/rgba()/hsl()/hsla() with
|
|
812
|
+
// numbers, commas, spaces, %, decimals and slashes, or a bare colour name.
|
|
813
|
+
color: /^(#[0-9a-fA-F]{3,8}|(rgb|rgba|hsl|hsla)\([0-9.,%\s/]+\)|[a-zA-Z]+)$/,
|
|
814
|
+
length: /^\d+(\.\d+)?(px|rem|em|%)$/
|
|
815
|
+
};
|
|
816
|
+
// Belt-and-braces: nothing that passed above may still carry CSS structure.
|
|
817
|
+
var RE_CSS_UNSAFE = /[;{}<>]/;
|
|
818
|
+
|
|
819
|
+
function sanitizeCssValue(kind, value, fallback) {
|
|
820
|
+
var v = str(value);
|
|
821
|
+
if (!v) return fallback;
|
|
822
|
+
var re = RE_CSS[kind];
|
|
823
|
+
if (!re || !re.test(v)) return fallback;
|
|
824
|
+
if (RE_CSS_UNSAFE.test(v)) return fallback;
|
|
825
|
+
return v;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// One :host{} block of custom properties for a resolved palette.
|
|
829
|
+
function tokenBlock(sel, p, radius) {
|
|
830
|
+
var d = [
|
|
831
|
+
'--ck-bg:' + p.bg,
|
|
832
|
+
'--ck-ink:' + p.ink,
|
|
833
|
+
'--ck-accent:' + p.accent,
|
|
834
|
+
'--ck-on-accent:' + p.onAccent,
|
|
835
|
+
'--ck-muted:' + p.muted,
|
|
836
|
+
'--ck-line:' + p.line,
|
|
837
|
+
'--ck-soft:' + p.soft
|
|
838
|
+
];
|
|
839
|
+
if (radius) d.push('--ck-radius:' + radius);
|
|
840
|
+
return sel + '{' + d.join(';') + '}';
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Derived tokens follow the explicit bg/ink the caller supplied, so a custom
|
|
844
|
+
// light palette keeps readable muted/line/soft values.
|
|
845
|
+
function derive(base, bg, ink) {
|
|
846
|
+
var p = {
|
|
847
|
+
bg: bg, ink: ink, accent: base.accent, onAccent: base.onAccent,
|
|
848
|
+
muted: base.muted, line: base.line, soft: base.soft
|
|
849
|
+
};
|
|
850
|
+
if (bg !== base.bg || ink !== base.ink) {
|
|
851
|
+
p.muted = 'color-mix(in srgb, ' + ink + ' 62%, ' + bg + ')';
|
|
852
|
+
p.line = 'color-mix(in srgb, ' + ink + ' 14%, ' + bg + ')';
|
|
853
|
+
p.soft = 'color-mix(in srgb, ' + ink + ' 5%, ' + bg + ')';
|
|
854
|
+
}
|
|
855
|
+
return p;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function buildThemeCss(cfg) {
|
|
859
|
+
var theme = (cfg && cfg.theme) || {};
|
|
860
|
+
var dk = (theme.dark && typeof theme.dark === 'object') ? theme.dark : {};
|
|
861
|
+
// Every value below is interpolated into stylesheet text — see
|
|
862
|
+
// sanitizeCssValue(). A rejected value falls back to the token default, so
|
|
863
|
+
// derive()'s "bg !== base.bg" check collapses to exactly the built-in
|
|
864
|
+
// palette rather than a half-substituted one.
|
|
865
|
+
var radius = sanitizeCssValue('length', theme.radius, LIGHT_RADIUS);
|
|
866
|
+
|
|
867
|
+
// Light: config overrides on top of the built-in light palette.
|
|
868
|
+
var light = derive(LIGHT,
|
|
869
|
+
sanitizeCssValue('color', theme.bg, LIGHT.bg),
|
|
870
|
+
sanitizeCssValue('color', theme.ink, LIGHT.ink));
|
|
871
|
+
if (str(theme.accent)) light.accent = sanitizeCssValue('color', theme.accent, LIGHT.accent);
|
|
872
|
+
|
|
873
|
+
// Dark: theme.dark overrides on top of the built-in dark palette.
|
|
874
|
+
// A light-only theme.accent deliberately does NOT carry into dark — the
|
|
875
|
+
// default #2B50D8 on #1A202D is ~2.5:1 and would fail AA.
|
|
876
|
+
var dark = derive(DARK,
|
|
877
|
+
sanitizeCssValue('color', dk.bg, DARK.bg),
|
|
878
|
+
sanitizeCssValue('color', dk.ink, DARK.ink));
|
|
879
|
+
if (str(dk.accent)) dark.accent = sanitizeCssValue('color', dk.accent, DARK.accent);
|
|
880
|
+
if (str(dk.onAccent)) dark.onAccent = sanitizeCssValue('color', dk.onAccent, DARK.onAccent);
|
|
881
|
+
|
|
882
|
+
var mode = theme.mode;
|
|
883
|
+
if (mode !== 'light' && mode !== 'dark') mode = 'auto';
|
|
884
|
+
|
|
885
|
+
var out = [tokenBlock(':host', light, radius)];
|
|
886
|
+
if (mode === 'auto') {
|
|
887
|
+
// forced-light class must still beat a dark system preference
|
|
888
|
+
out.push('@media (prefers-color-scheme: dark){' +
|
|
889
|
+
tokenBlock(':host(:not(.ck-mode-light))', dark, null) + '}');
|
|
890
|
+
}
|
|
891
|
+
out.push(tokenBlock(':host(.ck-mode-dark)', dark, null));
|
|
892
|
+
|
|
893
|
+
// color-mix fallback, per context, so a custom palette without color-mix
|
|
894
|
+
// still lands on readable static values rather than transparent.
|
|
895
|
+
out.push('@supports not (color: color-mix(in srgb, #000 50%, #fff)){' +
|
|
896
|
+
':host{--ck-muted:' + LIGHT.muted + ';--ck-line:' + LIGHT.line + ';--ck-soft:' + LIGHT.soft + '}' +
|
|
897
|
+
(mode === 'auto'
|
|
898
|
+
? '@media (prefers-color-scheme: dark){:host(:not(.ck-mode-light)){--ck-muted:' + DARK.muted +
|
|
899
|
+
';--ck-line:' + DARK.line + ';--ck-soft:' + DARK.soft + '}}'
|
|
900
|
+
: '') +
|
|
901
|
+
':host(.ck-mode-dark){--ck-muted:' + DARK.muted + ';--ck-line:' + DARK.line +
|
|
902
|
+
';--ck-soft:' + DARK.soft + '}}');
|
|
903
|
+
|
|
904
|
+
return { css: out.join('\n'), mode: mode };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function applyTheme(cfg) {
|
|
908
|
+
if (!host || !nodes.themeStyle) return;
|
|
909
|
+
var built = buildThemeCss(cfg);
|
|
910
|
+
// Brand rules ride along in the same sheet: the dark/light logo swap depends
|
|
911
|
+
// on theme.mode, so it must be rebuilt whenever the palette is.
|
|
912
|
+
nodes.themeStyle.textContent = built.css + '\n' + buildBrandCss(cfg); // replace, never append
|
|
913
|
+
host.classList.remove('ck-mode-dark', 'ck-mode-light');
|
|
914
|
+
if (built.mode === 'dark') host.classList.add('ck-mode-dark');
|
|
915
|
+
else if (built.mode === 'light') host.classList.add('ck-mode-light');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function makeSwitch(cat, locked) {
|
|
919
|
+
var b = el('button', 'ck-switch');
|
|
920
|
+
b.type = 'button';
|
|
921
|
+
b.setAttribute('role', 'switch');
|
|
922
|
+
b.setAttribute('aria-checked', locked ? 'true' : 'false');
|
|
923
|
+
b.dataset.cat = cat;
|
|
924
|
+
if (locked) {
|
|
925
|
+
b.disabled = true;
|
|
926
|
+
b.setAttribute('aria-disabled', 'true');
|
|
927
|
+
} else {
|
|
928
|
+
b.addEventListener('click', function () {
|
|
929
|
+
var on = b.getAttribute('aria-checked') === 'true';
|
|
930
|
+
b.setAttribute('aria-checked', on ? 'false' : 'true');
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
return b;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function buildCategory(cfg, cat) {
|
|
937
|
+
var meta = T.cat[cat] || { title: cat, desc: '' };
|
|
938
|
+
var locked = cat === 'necessary';
|
|
939
|
+
|
|
940
|
+
var wrap = el('div', 'ck-cat');
|
|
941
|
+
var top = el('div', 'ck-cat__top');
|
|
942
|
+
var txt = el('div', 'ck-cat__txt');
|
|
943
|
+
|
|
944
|
+
var nameId = 'ck-cat-' + cat;
|
|
945
|
+
var name = el('div', 'ck-cat__name');
|
|
946
|
+
var nameSpan = el('span', null, meta.title);
|
|
947
|
+
nameSpan.id = nameId;
|
|
948
|
+
name.appendChild(nameSpan);
|
|
949
|
+
if (locked) name.appendChild(el('span', 'ck-cat__badge', T.alwaysOn));
|
|
950
|
+
txt.appendChild(name);
|
|
951
|
+
|
|
952
|
+
var descId = nameId + '-desc';
|
|
953
|
+
var desc = el('p', 'ck-cat__desc', meta.desc);
|
|
954
|
+
desc.id = descId;
|
|
955
|
+
txt.appendChild(desc);
|
|
956
|
+
|
|
957
|
+
var sw = makeSwitch(cat, locked);
|
|
958
|
+
sw.setAttribute('aria-labelledby', nameId);
|
|
959
|
+
sw.setAttribute('aria-describedby', descId);
|
|
960
|
+
switches[cat] = sw;
|
|
961
|
+
|
|
962
|
+
top.appendChild(txt);
|
|
963
|
+
top.appendChild(sw);
|
|
964
|
+
wrap.appendChild(top);
|
|
965
|
+
|
|
966
|
+
var rows = cookiesFor(cfg, cat);
|
|
967
|
+
if (rows.length) {
|
|
968
|
+
var det = el('details', 'ck-det');
|
|
969
|
+
var sum = el('summary');
|
|
970
|
+
sum.appendChild(document.createTextNode(T.cookiesIn + ' (' + rows.length + ')'));
|
|
971
|
+
det.appendChild(sum);
|
|
972
|
+
|
|
973
|
+
var tw = el('div', 'ck-tablewrap');
|
|
974
|
+
var table = el('table');
|
|
975
|
+
var thead = el('thead');
|
|
976
|
+
var htr = el('tr');
|
|
977
|
+
var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
|
|
978
|
+
for (var h = 0; h < heads.length; h++) {
|
|
979
|
+
var th = el('th', null, heads[h]);
|
|
980
|
+
th.setAttribute('scope', 'col');
|
|
981
|
+
htr.appendChild(th);
|
|
982
|
+
}
|
|
983
|
+
thead.appendChild(htr);
|
|
984
|
+
table.appendChild(thead);
|
|
985
|
+
|
|
986
|
+
var tbody = el('tbody');
|
|
987
|
+
for (var r = 0; r < rows.length; r++) {
|
|
988
|
+
var row = rows[r];
|
|
989
|
+
var tr = el('tr');
|
|
990
|
+
tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
|
|
991
|
+
tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
|
|
992
|
+
tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
|
|
993
|
+
tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
|
|
994
|
+
tbody.appendChild(tr);
|
|
995
|
+
}
|
|
996
|
+
table.appendChild(tbody);
|
|
997
|
+
tw.appendChild(table);
|
|
998
|
+
det.appendChild(tw);
|
|
999
|
+
wrap.appendChild(det);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return wrap;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Unknown type -> bar/bottom. Known type with an unrecognized position ->
|
|
1006
|
+
// that type's own default (bar: bottom, box: bottom-left — the side away
|
|
1007
|
+
// from the chat widgets and scroll-to-top buttons most sites put on the right).
|
|
1008
|
+
function resolveLayout(cfg) {
|
|
1009
|
+
var layout = (cfg && cfg.layout) || {};
|
|
1010
|
+
var type = layout.type;
|
|
1011
|
+
if (type !== 'modal' && type !== 'box') type = 'bar';
|
|
1012
|
+
var pos = layout.position;
|
|
1013
|
+
if (type === 'bar') pos = (pos === 'top') ? 'top' : 'bottom';
|
|
1014
|
+
else if (type === 'box') pos = (pos === 'bottom-right') ? 'bottom-right' : 'bottom-left';
|
|
1015
|
+
else pos = null; // modal is centered
|
|
1016
|
+
return { type: type, position: pos };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function buildBanner(cfg) {
|
|
1020
|
+
var lay = resolveLayout(cfg);
|
|
1021
|
+
var isModal = lay.type === 'modal';
|
|
1022
|
+
|
|
1023
|
+
var scrim = el('div', 'ck-scrim ck-hidden'); // decorative, never blocks scroll
|
|
1024
|
+
scrim.setAttribute('aria-hidden', 'true');
|
|
1025
|
+
if (!isModal) scrim.classList.add('ck-hidden');
|
|
1026
|
+
|
|
1027
|
+
var b = el('section', 'ck-banner ck-hidden');
|
|
1028
|
+
b.className = 'ck-banner ck-hidden ck-banner--' + lay.type +
|
|
1029
|
+
(lay.position ? ' ck-pos-' + lay.position : '');
|
|
1030
|
+
b.setAttribute('role', 'region');
|
|
1031
|
+
b.setAttribute('aria-label', T.bannerLabel);
|
|
1032
|
+
|
|
1033
|
+
var body = el('div', 'ck-banner__body');
|
|
1034
|
+
var h = el('h2', null, T.bannerTitle);
|
|
1035
|
+
h.id = 'ck-banner-title';
|
|
1036
|
+
body.appendChild(h);
|
|
1037
|
+
|
|
1038
|
+
var p = el('p');
|
|
1039
|
+
p.appendChild(document.createTextNode(T.bannerText + ' '));
|
|
1040
|
+
var link = el('a', 'ck-banner__more', T.more);
|
|
1041
|
+
link.href = '#';
|
|
1042
|
+
p.appendChild(link);
|
|
1043
|
+
body.appendChild(p);
|
|
1044
|
+
|
|
1045
|
+
var pb = buildPoweredBy(cfg);
|
|
1046
|
+
var brand = buildBrandLogo(cfg);
|
|
1047
|
+
var foot = null;
|
|
1048
|
+
if (brand || pb) {
|
|
1049
|
+
foot = el('div', 'ck-foot');
|
|
1050
|
+
if (brand) foot.appendChild(brand);
|
|
1051
|
+
if (pb) foot.appendChild(pb);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Where the attribution goes depends on the layout's flow direction.
|
|
1055
|
+
// bar: text and actions sit side by side and are centred against each
|
|
1056
|
+
// other, so a full-width row underneath would stretch the first row and
|
|
1057
|
+
// leave the buttons floating — the foot belongs at the end of the text
|
|
1058
|
+
// column instead. box/modal stack vertically, so it simply follows the
|
|
1059
|
+
// buttons, which is also the correct reading order there.
|
|
1060
|
+
if (foot && lay.type === 'bar') body.appendChild(foot);
|
|
1061
|
+
b.appendChild(body);
|
|
1062
|
+
|
|
1063
|
+
var actions = el('div', 'ck-actions');
|
|
1064
|
+
var accept = el('button', 'ck-btn ck-btn--filled', T.acceptAll);
|
|
1065
|
+
accept.type = 'button';
|
|
1066
|
+
var reject = el('button', 'ck-btn ck-btn--filled', T.rejectAll);
|
|
1067
|
+
reject.type = 'button';
|
|
1068
|
+
var custom = el('button', 'ck-btn ck-btn--outline', T.customize);
|
|
1069
|
+
custom.type = 'button';
|
|
1070
|
+
|
|
1071
|
+
accept.addEventListener('click', function () { doAcceptAll(); });
|
|
1072
|
+
reject.addEventListener('click', function () { doRejectAll(); });
|
|
1073
|
+
custom.addEventListener('click', function () { openPanel(custom); });
|
|
1074
|
+
|
|
1075
|
+
actions.appendChild(accept);
|
|
1076
|
+
actions.appendChild(reject);
|
|
1077
|
+
actions.appendChild(custom);
|
|
1078
|
+
b.appendChild(actions);
|
|
1079
|
+
|
|
1080
|
+
// box/modal: the foot follows the buttons (see the note above).
|
|
1081
|
+
if (foot && lay.type !== 'bar') b.appendChild(foot);
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
nodes.scrim = scrim;
|
|
1085
|
+
nodes.banner = b;
|
|
1086
|
+
nodes.bannerModal = isModal;
|
|
1087
|
+
root.appendChild(scrim);
|
|
1088
|
+
root.appendChild(b);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function buildPanel(cfg) {
|
|
1092
|
+
var scrim = el('div', 'ck-panel-scrim ck-hidden');
|
|
1093
|
+
scrim.setAttribute('aria-hidden', 'true');
|
|
1094
|
+
|
|
1095
|
+
var p = el('div', 'ck-panel ck-hidden');
|
|
1096
|
+
p.setAttribute('role', 'dialog');
|
|
1097
|
+
p.setAttribute('aria-modal', 'true');
|
|
1098
|
+
p.setAttribute('aria-label', T.panelTitle);
|
|
1099
|
+
p.tabIndex = -1;
|
|
1100
|
+
|
|
1101
|
+
var head = el('div', 'ck-panel__head');
|
|
1102
|
+
var htxt = el('div');
|
|
1103
|
+
htxt.appendChild(el('h2', null, T.panelTitle));
|
|
1104
|
+
htxt.appendChild(el('p', null, T.panelIntro));
|
|
1105
|
+
head.appendChild(htxt);
|
|
1106
|
+
|
|
1107
|
+
var x = el('button', 'ck-x');
|
|
1108
|
+
x.type = 'button';
|
|
1109
|
+
x.setAttribute('aria-label', T.close);
|
|
1110
|
+
x.appendChild(document.createTextNode('✕'));
|
|
1111
|
+
x.addEventListener('click', function () { closePanel(); });
|
|
1112
|
+
head.appendChild(x);
|
|
1113
|
+
p.appendChild(head);
|
|
1114
|
+
|
|
1115
|
+
var body = el('div', 'ck-panel__body');
|
|
1116
|
+
var cats = ['necessary'].concat(activeOptIn(cfg));
|
|
1117
|
+
for (var i = 0; i < cats.length; i++) body.appendChild(buildCategory(cfg, cats[i]));
|
|
1118
|
+
p.appendChild(body);
|
|
1119
|
+
|
|
1120
|
+
var foot = el('div', 'ck-panel__foot');
|
|
1121
|
+
var save = el('button', 'ck-btn ck-btn--filled', T.save);
|
|
1122
|
+
save.type = 'button';
|
|
1123
|
+
var acc = el('button', 'ck-btn ck-btn--outline', T.acceptAll);
|
|
1124
|
+
acc.type = 'button';
|
|
1125
|
+
var rej = el('button', 'ck-btn ck-btn--outline', T.rejectAll);
|
|
1126
|
+
rej.type = 'button';
|
|
1127
|
+
|
|
1128
|
+
save.addEventListener('click', function () { doSave(); });
|
|
1129
|
+
acc.addEventListener('click', function () { doAcceptAll(); });
|
|
1130
|
+
rej.addEventListener('click', function () { doRejectAll(); });
|
|
1131
|
+
|
|
1132
|
+
foot.appendChild(save);
|
|
1133
|
+
foot.appendChild(acc);
|
|
1134
|
+
foot.appendChild(rej);
|
|
1135
|
+
// Same attribution foot as the banner: mark and credit sign the bottom,
|
|
1136
|
+
// below the action buttons, never the panel heading.
|
|
1137
|
+
var pfoot = buildPoweredBy(cfg);
|
|
1138
|
+
var pbrand = buildBrandLogo(cfg);
|
|
1139
|
+
if (pbrand || pfoot) {
|
|
1140
|
+
var pfootWrap = el('div', 'ck-foot');
|
|
1141
|
+
if (pbrand) pfootWrap.appendChild(pbrand);
|
|
1142
|
+
if (pfoot) pfootWrap.appendChild(pfoot);
|
|
1143
|
+
foot.appendChild(pfootWrap);
|
|
1144
|
+
}
|
|
1145
|
+
p.appendChild(foot);
|
|
1146
|
+
|
|
1147
|
+
p.addEventListener('keydown', onPanelKeydown);
|
|
1148
|
+
|
|
1149
|
+
nodes.panelScrim = scrim;
|
|
1150
|
+
nodes.panel = p;
|
|
1151
|
+
root.appendChild(scrim);
|
|
1152
|
+
root.appendChild(p);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function buildFab() {
|
|
1156
|
+
var f = el('button', 'ck-fab ck-hidden');
|
|
1157
|
+
f.type = 'button';
|
|
1158
|
+
f.setAttribute('aria-label', T.floating);
|
|
1159
|
+
f.title = T.floating;
|
|
1160
|
+
f.innerHTML = COOKIE_ICON; // static literal, no config data
|
|
1161
|
+
f.addEventListener('click', function () { openPanel(f); });
|
|
1162
|
+
nodes.fab = f;
|
|
1163
|
+
root.appendChild(f);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/* -------------------------------------------------------------- a11y/trap */
|
|
1167
|
+
|
|
1168
|
+
function focusables() {
|
|
1169
|
+
if (!nodes.panel) return [];
|
|
1170
|
+
var sel = 'button:not([disabled]),a[href],summary,input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
1171
|
+
var all = nodes.panel.querySelectorAll(sel);
|
|
1172
|
+
var out = [];
|
|
1173
|
+
for (var i = 0; i < all.length; i++) {
|
|
1174
|
+
var n = all[i];
|
|
1175
|
+
if (n.hasAttribute('disabled')) continue;
|
|
1176
|
+
if (n.offsetParent === null && n.getClientRects().length === 0) continue;
|
|
1177
|
+
out.push(n);
|
|
1178
|
+
}
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function onPanelKeydown(e) {
|
|
1183
|
+
if (e.key === 'Escape' || e.key === 'Esc') {
|
|
1184
|
+
e.preventDefault();
|
|
1185
|
+
e.stopPropagation();
|
|
1186
|
+
closePanel();
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (e.key !== 'Tab') return;
|
|
1190
|
+
var list = focusables(); // queried live: <details> changes the set
|
|
1191
|
+
if (!list.length) { e.preventDefault(); return; }
|
|
1192
|
+
var current = root.activeElement || document.activeElement;
|
|
1193
|
+
var idx = list.indexOf(current);
|
|
1194
|
+
var next;
|
|
1195
|
+
if (e.shiftKey) next = idx <= 0 ? list[list.length - 1] : list[idx - 1];
|
|
1196
|
+
else next = (idx === -1 || idx === list.length - 1) ? list[0] : list[idx + 1];
|
|
1197
|
+
e.preventDefault();
|
|
1198
|
+
next.focus();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/* ---------------------------------------------------------------- actions */
|
|
1202
|
+
|
|
1203
|
+
function readSwitches() {
|
|
1204
|
+
var out = {};
|
|
1205
|
+
for (var i = 0; i < OPT_IN.length; i++) {
|
|
1206
|
+
var k = OPT_IN[i];
|
|
1207
|
+
var sw = switches[k];
|
|
1208
|
+
out[k] = !!(sw && sw.getAttribute('aria-checked') === 'true');
|
|
1209
|
+
}
|
|
1210
|
+
return out;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function doAcceptAll() {
|
|
1214
|
+
var ck = api();
|
|
1215
|
+
try { if (ck && typeof ck.accept === 'function') ck.accept('all'); } catch (e) {}
|
|
1216
|
+
closePanel(true);
|
|
1217
|
+
syncFromState();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function doRejectAll() {
|
|
1221
|
+
var ck = api();
|
|
1222
|
+
try { if (ck && typeof ck.rejectAll === 'function') ck.rejectAll(); } catch (e) {}
|
|
1223
|
+
closePanel(true);
|
|
1224
|
+
syncFromState();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Saving without touching anything is a valid refusal of every opt-in category.
|
|
1228
|
+
function doSave() {
|
|
1229
|
+
var ck = api();
|
|
1230
|
+
var choice = readSwitches();
|
|
1231
|
+
try { if (ck && typeof ck.accept === 'function') ck.accept(choice); } catch (e) {}
|
|
1232
|
+
closePanel(true);
|
|
1233
|
+
syncFromState();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/* ------------------------------------------------------------ open/close */
|
|
1237
|
+
|
|
1238
|
+
function openPanel(invoker) {
|
|
1239
|
+
if (!mounted || !nodes.panel) return;
|
|
1240
|
+
lastFocus = invoker || root.activeElement || document.activeElement;
|
|
1241
|
+
syncSwitches(safeState());
|
|
1242
|
+
nodes.panelScrim.classList.remove('ck-hidden');
|
|
1243
|
+
nodes.panel.classList.remove('ck-hidden');
|
|
1244
|
+
panelOpen = true;
|
|
1245
|
+
try { nodes.panel.focus(); } catch (e) {}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function closePanel(skipRestore) {
|
|
1249
|
+
if (!nodes.panel) return;
|
|
1250
|
+
var wasOpen = panelOpen;
|
|
1251
|
+
nodes.panelScrim.classList.add('ck-hidden');
|
|
1252
|
+
nodes.panel.classList.add('ck-hidden');
|
|
1253
|
+
panelOpen = false;
|
|
1254
|
+
if (wasOpen && !skipRestore && lastFocus && typeof lastFocus.focus === 'function') {
|
|
1255
|
+
try { if (lastFocus.isConnected !== false) lastFocus.focus(); } catch (e) {}
|
|
1256
|
+
}
|
|
1257
|
+
lastFocus = null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/* ------------------------------------------------------------------ sync */
|
|
1261
|
+
|
|
1262
|
+
function syncSwitches(state) {
|
|
1263
|
+
var cats = (state && state.categories) || {};
|
|
1264
|
+
for (var i = 0; i < OPT_IN.length; i++) {
|
|
1265
|
+
var k = OPT_IN[i];
|
|
1266
|
+
var sw = switches[k];
|
|
1267
|
+
if (!sw) continue;
|
|
1268
|
+
// before a decision every opt-in switch stays off
|
|
1269
|
+
var on = state && state.decided ? cats[k] === true : false;
|
|
1270
|
+
sw.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
1271
|
+
}
|
|
1272
|
+
if (switches.necessary) switches.necessary.setAttribute('aria-checked', 'true');
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Idempotent: safe to call from ck:init, ck:change and right after our own API calls.
|
|
1276
|
+
function syncFromState(state) {
|
|
1277
|
+
if (!mounted) return;
|
|
1278
|
+
var s = state || safeState();
|
|
1279
|
+
var decided = !!s.decided;
|
|
1280
|
+
|
|
1281
|
+
if (nodes.banner) nodes.banner.classList.toggle('ck-hidden', decided);
|
|
1282
|
+
if (nodes.scrim) nodes.scrim.classList.toggle('ck-hidden', decided || !nodes.bannerModal);
|
|
1283
|
+
if (nodes.fab) nodes.fab.classList.toggle('ck-hidden', !decided);
|
|
1284
|
+
if (!panelOpen) syncSwitches(s);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/* ----------------------------------------------------------------- mount */
|
|
1288
|
+
|
|
1289
|
+
// Structural inputs: a change to any of these needs a rebuild, not a restyle.
|
|
1290
|
+
// Branding belongs here because it produces DOM, not just styling: mount() is
|
|
1291
|
+
// one-shot, so a config that gains a logo after the first ck:init would
|
|
1292
|
+
// otherwise take the applyTheme()-only path and never render it.
|
|
1293
|
+
function brandSignature(cfg) {
|
|
1294
|
+
var b = brandingCfg(cfg);
|
|
1295
|
+
if (!b) return '-';
|
|
1296
|
+
var pb = b.poweredBy;
|
|
1297
|
+
var pbSig = (pb && typeof pb === 'object')
|
|
1298
|
+
? 'o:' + String(pb.text || '') + ':' + String(pb.url || '')
|
|
1299
|
+
: String(!!pb);
|
|
1300
|
+
// Logos are hashed by length + head so a long data: URI does not bloat the key.
|
|
1301
|
+
function tag(v) {
|
|
1302
|
+
var s = str(v);
|
|
1303
|
+
return s ? (s.length + ':' + s.slice(0, 32)) : '-';
|
|
1304
|
+
}
|
|
1305
|
+
return [
|
|
1306
|
+
tag(b.logo), tag(b.logoDark), String(b.logoAlt || ''),
|
|
1307
|
+
String(clampLogoHeight(b.logoHeight)), String(b.logoUrl || ''), pbSig
|
|
1308
|
+
].join('~');
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function signature(cfg) {
|
|
1312
|
+
var c = cfg || {};
|
|
1313
|
+
var lay = resolveLayout(c);
|
|
1314
|
+
var table = c.cookieTable;
|
|
1315
|
+
return [
|
|
1316
|
+
String(c.language || 'auto'),
|
|
1317
|
+
lay.type, String(lay.position),
|
|
1318
|
+
Array.isArray(table) ? table.length : 0,
|
|
1319
|
+
brandSignature(c)
|
|
1320
|
+
].join('|');
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function remount(cfg) {
|
|
1324
|
+
mounted = false;
|
|
1325
|
+
panelOpen = false;
|
|
1326
|
+
lastFocus = null;
|
|
1327
|
+
mount(cfg);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function mount(cfg) {
|
|
1331
|
+
if (mounted) return;
|
|
1332
|
+
if (!document.body) {
|
|
1333
|
+
document.addEventListener('DOMContentLoaded', function () { mount(safeConfig()); }, { once: true });
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
var table = localeTable(); // read at render time
|
|
1338
|
+
T = buildStrings(resolveLang(cfg && cfg.language, table), table);
|
|
1339
|
+
|
|
1340
|
+
host = document.getElementById('ck-root');
|
|
1341
|
+
if (!host) {
|
|
1342
|
+
host = document.createElement('div');
|
|
1343
|
+
host.id = 'ck-root';
|
|
1344
|
+
document.body.appendChild(host);
|
|
1345
|
+
}
|
|
1346
|
+
root = host.shadowRoot || host.attachShadow({ mode: 'open' });
|
|
1347
|
+
root.innerHTML = '';
|
|
1348
|
+
|
|
1349
|
+
var style = document.createElement('style');
|
|
1350
|
+
style.textContent = CSS;
|
|
1351
|
+
root.appendChild(style);
|
|
1352
|
+
|
|
1353
|
+
switches = {};
|
|
1354
|
+
nodes = {};
|
|
1355
|
+
|
|
1356
|
+
// Palette sheet comes after the base sheet so its :host tokens win.
|
|
1357
|
+
nodes.themeStyle = document.createElement('style');
|
|
1358
|
+
root.appendChild(nodes.themeStyle);
|
|
1359
|
+
applyTheme(cfg);
|
|
1360
|
+
|
|
1361
|
+
buildBanner(cfg);
|
|
1362
|
+
buildPanel(cfg);
|
|
1363
|
+
buildFab();
|
|
1364
|
+
|
|
1365
|
+
mounted = true;
|
|
1366
|
+
mountedSig = signature(cfg);
|
|
1367
|
+
syncFromState();
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/* ---------------------------------------------------------------- events */
|
|
1371
|
+
|
|
1372
|
+
// SSR-safe: with no DOM there is nothing to render or listen to, so importing
|
|
1373
|
+
// this file in Node is a no-op rather than a throw (mirrors the core).
|
|
1374
|
+
if (typeof document === 'undefined') return;
|
|
1375
|
+
|
|
1376
|
+
document.addEventListener('ck:init', function (e) {
|
|
1377
|
+
var d = (e && e.detail) || {};
|
|
1378
|
+
var cfg = d.config || safeConfig();
|
|
1379
|
+
if (!mounted) {
|
|
1380
|
+
mount(cfg);
|
|
1381
|
+
} else if (signature(cfg) !== mountedSig) {
|
|
1382
|
+
remount(cfg); // language/layout changed since the first render
|
|
1383
|
+
} else {
|
|
1384
|
+
applyTheme(cfg); // palette-only changes need no rebuild
|
|
1385
|
+
}
|
|
1386
|
+
syncFromState(d.state);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
document.addEventListener('ck:change', function (e) {
|
|
1390
|
+
var d = (e && e.detail) || {};
|
|
1391
|
+
syncFromState(d.state);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
// Insurance: sync is idempotent, so a core that only signals the first choice
|
|
1395
|
+
// via ck:consent still updates the UI.
|
|
1396
|
+
document.addEventListener('ck:consent', function (e) {
|
|
1397
|
+
var d = (e && e.detail) || {};
|
|
1398
|
+
syncFromState(d.state);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
document.addEventListener('ck:ui:open-preferences', function () {
|
|
1402
|
+
if (!mounted) mount(safeConfig());
|
|
1403
|
+
openPanel(null);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
document.addEventListener('ck:ui:close', function () {
|
|
1407
|
+
closePanel();
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
// Fallback for a missed ck:init (this file loaded after init() already ran).
|
|
1411
|
+
// Deferred to a macrotask on purpose: the core publishes a DEFAULT config at
|
|
1412
|
+
// parse time, so mounting synchronously here would render with those defaults
|
|
1413
|
+
// before the real init() config arrives and — mount() being one-shot — lock in
|
|
1414
|
+
// the wrong language and layout. By the time the timeout runs, a normally
|
|
1415
|
+
// ordered page has already dispatched ck:init and mounted, so this no-ops;
|
|
1416
|
+
// only a genuinely missed ck:init reaches mount(), and by then
|
|
1417
|
+
// ConsentKit.config holds the merged real config.
|
|
1418
|
+
setTimeout(function () {
|
|
1419
|
+
if (!mounted && api() && api().config) mount(safeConfig());
|
|
1420
|
+
}, 0);
|
|
1421
|
+
})();
|