@c15t/svelte 3.0.0-alpha.0 → 3.0.0-alpha.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/AGENTS.md +3 -0
- package/dist/components/{frame.svelte.d.ts → consent-gate.svelte.d.ts} +3 -3
- package/dist/components/iab-purpose-item.svelte +3 -1
- package/dist/components/iab-stack-item.svelte +1 -1
- package/dist/components/iab-vendor-list.svelte +2 -0
- package/dist/components/manager-provider.svelte +207 -34
- package/dist/components/preferences.svelte +10 -10
- package/dist/components/vendor-list.svelte +190 -0
- package/dist/components/vendor-list.svelte.d.ts +18 -0
- package/dist/context.svelte.d.ts +36 -2
- package/dist/context.svelte.js +19 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/kit/routes.js +26 -1
- package/dist/kit/types.d.ts +10 -0
- package/dist/primitives/preference-item/index.d.ts +1 -0
- package/dist/primitives/preference-item/preference-item-content.svelte +17 -3
- package/dist/primitives/preference-item/preference-item-content.svelte.d.ts +10 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/README.md +3 -0
- package/docs/frameworks/sveltekit/quickstart.md +4 -0
- package/docs/guides/deployment-modes.md +12 -0
- package/docs/integrations/building-integrations.md +5 -0
- package/docs/integrations/clear-on-revocation.md +167 -0
- package/docs/integrations/cloudflare-zaraz.md +399 -0
- package/docs/integrations/google-maps.md +20 -20
- package/docs/integrations/granular-consent.md +208 -0
- package/docs/integrations/overview.md +5 -4
- package/docs/integrations/youtube.md +26 -21
- package/docs/upgrade-v3.md +47 -0
- package/package.json +7 -7
- package/readme.json +0 -36
- /package/dist/components/{frame.svelte → consent-gate.svelte} +0 -0
package/AGENTS.md
CHANGED
|
@@ -47,14 +47,17 @@ These docs describe v3. Start with Inth hosted setup, identify the framework, ro
|
|
|
47
47
|
- [Ahrefs Analytics](./docs/integrations/ahrefs-analytics.md): Configure Ahrefs Analytics with c15t v3, understand measurement permission and verify loading and revocation.
|
|
48
48
|
- [Amplitude](./docs/integrations/amplitude.md): Configure Amplitude with c15t v3, understand measurement permission and verify loading and revocation.
|
|
49
49
|
- [Custom integrations](./docs/integrations/building-integrations.md): Define loading, initialization and consent-change behavior for a vendor without a helper.
|
|
50
|
+
- [Clear on revocation](./docs/integrations/clear-on-revocation.md): Remove configured first-party cookies and Web Storage keys when their consent category is denied.
|
|
50
51
|
- [Clearbit](./docs/integrations/clearbit.md): Configure Clearbit with c15t v3, understand marketing permission and verify loading and revocation.
|
|
51
52
|
- [Cloudflare Web Analytics](./docs/integrations/cloudflare-web-analytics.md): Configure Cloudflare Web Analytics with c15t v3, understand measurement permission and verify loading and revocation.
|
|
53
|
+
- [Cloudflare Zaraz](./docs/integrations/cloudflare-zaraz.md): Synchronize c15t permissions with Zaraz purposes while Cloudflare manages your tools.
|
|
52
54
|
- [Crisp](./docs/integrations/crisp.md): Configure Crisp with c15t v3, understand functionality permission and verify loading and revocation.
|
|
53
55
|
- [Databuddy](./docs/integrations/databuddy.md): Configure Databuddy's initial and updated consent state with c15t v3.
|
|
54
56
|
- [Fathom Analytics](./docs/integrations/fathom-analytics.md): Configure Fathom Analytics with c15t v3, understand measurement permission and verify loading and revocation.
|
|
55
57
|
- [Google Maps](./docs/integrations/google-maps.md): Prevent a map iframe from mounting before the required permission.
|
|
56
58
|
- [Google Tag](./docs/integrations/google-tag.md): Configure gtag with c15t Consent Mode signals and understand its loading behavior.
|
|
57
59
|
- [Google Tag Manager](./docs/integrations/google-tag-manager.md): Load GTM with c15t consent signals and verify the tags inside your container.
|
|
60
|
+
- [Granular consent](./docs/integrations/granular-consent.md): Let visitors grant a category and still turn one vendor off, without adopting IAB TCF.
|
|
58
61
|
- [Heap](./docs/integrations/heap.md): Configure Heap with c15t v3, understand measurement permission and verify loading and revocation.
|
|
59
62
|
- [Hightouch](./docs/integrations/hightouch.md): Configure Hightouch with c15t v3, understand measurement permission and verify loading and revocation.
|
|
60
63
|
- [Hotjar](./docs/integrations/hotjar.md): Configure Hotjar with c15t v3, understand measurement permission and verify loading and revocation.
|
|
@@ -7,6 +7,6 @@ type $$ComponentProps = {
|
|
|
7
7
|
noStyle?: boolean;
|
|
8
8
|
class?: string;
|
|
9
9
|
};
|
|
10
|
-
declare const
|
|
11
|
-
type
|
|
12
|
-
export default
|
|
10
|
+
declare const ConsentGate: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type ConsentGate = ReturnType<typeof ConsentGate>;
|
|
12
|
+
export default ConsentGate;
|
|
@@ -183,6 +183,7 @@
|
|
|
183
183
|
its own padding's worth. -->
|
|
184
184
|
<PreferenceItem.Content
|
|
185
185
|
innerClassName={noStyle ? '' : styles.purposeContent || ''}
|
|
186
|
+
{noStyle}
|
|
186
187
|
>
|
|
187
188
|
<p class={noStyle ? '' : styles.purposeDescription || ''}>
|
|
188
189
|
{purpose.description}
|
|
@@ -252,7 +253,7 @@
|
|
|
252
253
|
{iabT.preferenceCenter.purposeItem.examples}
|
|
253
254
|
({purpose.illustrations.length})
|
|
254
255
|
</PreferenceItem.Trigger>
|
|
255
|
-
<PreferenceItem.Content>
|
|
256
|
+
<PreferenceItem.Content {noStyle}>
|
|
256
257
|
<ul class={noStyle ? '' : styles.examplesList || ''}>
|
|
257
258
|
{#each purpose.illustrations as illustration (illustration)}
|
|
258
259
|
<li>{illustration}</li>
|
|
@@ -281,6 +282,7 @@
|
|
|
281
282
|
</PreferenceItem.Trigger>
|
|
282
283
|
<PreferenceItem.Content
|
|
283
284
|
innerClassName={noStyle ? '' : styles.vendorSection || ''}
|
|
285
|
+
{noStyle}
|
|
284
286
|
>
|
|
285
287
|
<!-- IAB Consent Vendors -->
|
|
286
288
|
{#if iabConsentVendors.length > 0}
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
|
|
424
424
|
<PreferenceItem.Content
|
|
425
425
|
innerClassName={noStyle ? '' : styles.vendorListContent || ''}
|
|
426
|
+
{noStyle}
|
|
426
427
|
>
|
|
427
428
|
<!-- Links -->
|
|
428
429
|
<div class={noStyle ? '' : styles.vendorLinks || ''}>
|
|
@@ -764,6 +765,7 @@
|
|
|
764
765
|
|
|
765
766
|
<PreferenceItem.Content
|
|
766
767
|
innerClassName={noStyle ? '' : styles.vendorListContent || ''}
|
|
768
|
+
{noStyle}
|
|
767
769
|
>
|
|
768
770
|
<div class={noStyle ? '' : styles.vendorLinks || ''}>
|
|
769
771
|
<a
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type {
|
|
3
|
-
AllConsentNames,
|
|
4
3
|
ConsentSnapshot,
|
|
5
4
|
ConsentState,
|
|
6
5
|
KernelOverrides,
|
|
7
6
|
KernelUser,
|
|
7
|
+
OptionalConsentCategory,
|
|
8
8
|
} from '@c15t/core';
|
|
9
|
+
import { deniedVendorIds, vendorRenders } from '@c15t/core';
|
|
9
10
|
import {
|
|
10
11
|
createConsentRuntime,
|
|
11
12
|
normalizeKernelUser,
|
|
@@ -99,29 +100,119 @@
|
|
|
99
100
|
const { kernel } = runtime;
|
|
100
101
|
|
|
101
102
|
let snapshot = $state<ConsentSnapshot>(kernel.getSnapshot());
|
|
103
|
+
let draftScope = $state<string | null>(null);
|
|
102
104
|
let draftFingerprint = $state<string | null>(null);
|
|
103
105
|
let draftRevision = 0;
|
|
104
106
|
let draftSaveSequence = 0;
|
|
105
107
|
let draftValues = $state<Partial<ConsentState>>({});
|
|
108
|
+
/** Vendor grants the visitor moved, over the seeded map. */
|
|
109
|
+
let draftVendors = $state<Record<string, boolean>>({});
|
|
110
|
+
/** The vendor list as it was when the first vendor moved. */
|
|
111
|
+
let draftVendorSurface = $state<string | null>(null);
|
|
112
|
+
|
|
113
|
+
/** Set an own property without going through the prototype for `__proto__`. */
|
|
114
|
+
const setOwn = (
|
|
115
|
+
target: Record<string, boolean>,
|
|
116
|
+
key: string,
|
|
117
|
+
value: boolean
|
|
118
|
+
) => {
|
|
119
|
+
Object.defineProperty(target, key, {
|
|
120
|
+
configurable: true,
|
|
121
|
+
enumerable: true,
|
|
122
|
+
value,
|
|
123
|
+
writable: true,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Granted flag per declared vendor from the record: the denials the gate
|
|
128
|
+
* honors, so a stale denial for a vendor now `disabled` does not count.
|
|
129
|
+
*/
|
|
130
|
+
const seedVendors = (current: ConsentSnapshot): Record<string, boolean> => {
|
|
131
|
+
const grants: Record<string, boolean> = {};
|
|
132
|
+
if (current.model === 'iab') {
|
|
133
|
+
return grants;
|
|
134
|
+
}
|
|
135
|
+
const denied = deniedVendorIds(current) ?? new Set<string>();
|
|
136
|
+
for (const vendor of current.vendors?.declared ?? []) {
|
|
137
|
+
setOwn(grants, vendor.id, !denied.has(vendor.id));
|
|
138
|
+
}
|
|
139
|
+
return grants;
|
|
140
|
+
};
|
|
141
|
+
const toggleableVendor = (current: ConsentSnapshot, vendorId: string) =>
|
|
142
|
+
current.model !== 'iab' &&
|
|
143
|
+
(current.vendors?.declared ?? []).some(
|
|
144
|
+
(vendor) => vendor.id === vendorId && vendor.disabled !== true
|
|
145
|
+
);
|
|
146
|
+
/**
|
|
147
|
+
* What the vendor rows are built from; a dirty draft goes stale when it
|
|
148
|
+
* changes. A declaration that cannot produce a row, such as a script
|
|
149
|
+
* registering only its slug, is not part of the surface.
|
|
150
|
+
*/
|
|
151
|
+
const vendorSurface = (current: ConsentSnapshot) =>
|
|
152
|
+
current.model === 'iab'
|
|
153
|
+
? ''
|
|
154
|
+
: JSON.stringify(
|
|
155
|
+
(current.vendors?.declared ?? [])
|
|
156
|
+
.filter(vendorRenders)
|
|
157
|
+
.map((vendor) => [
|
|
158
|
+
vendor.id,
|
|
159
|
+
vendor.disabled === true,
|
|
160
|
+
vendor.category,
|
|
161
|
+
])
|
|
162
|
+
);
|
|
163
|
+
/** The value a category shows before the visitor moves it. */
|
|
164
|
+
const baselineValue = (
|
|
165
|
+
current: ConsentSnapshot,
|
|
166
|
+
name: OptionalConsentCategory
|
|
167
|
+
) =>
|
|
168
|
+
current.explicitChoice?.categories[name]?.value ??
|
|
169
|
+
options.presentation?.preferences?.defaults?.[name] ??
|
|
170
|
+
(current.policyRule.model === 'opt-out' ||
|
|
171
|
+
current.policyRule.preselectedCategories.includes(name));
|
|
172
|
+
/**
|
|
173
|
+
* Forget the policy and vendor surface once nothing is staged. Both maps
|
|
174
|
+
* hold only moved values, so a visitor who moves a switch and moves it
|
|
175
|
+
* back leaves nothing to review: a later declaration must not make the
|
|
176
|
+
* draft stale, and a later policy must not find a value staged under
|
|
177
|
+
* the old one and save it. A save that fails never settles, so its draft
|
|
178
|
+
* stays for the retry.
|
|
179
|
+
*/
|
|
180
|
+
const settleDraft = () => {
|
|
181
|
+
if (
|
|
182
|
+
Object.keys(draftValues).length === 0 &&
|
|
183
|
+
Object.keys(draftVendors).length === 0
|
|
184
|
+
) {
|
|
185
|
+
draftFingerprint = null;
|
|
186
|
+
draftScope = null;
|
|
187
|
+
draftVendorSurface = null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
106
190
|
let iabHandle = $state<IABHandle | null>(
|
|
107
191
|
untrack(() => runtime.iab as IABHandle | null)
|
|
108
192
|
);
|
|
109
193
|
let iabTab = $state<'purposes' | 'vendors'>('purposes');
|
|
110
|
-
let configuredCategories = $state<AllConsentNames[]>(
|
|
111
|
-
untrack(() => options.consentCategories ?? runtime.consentCategories)
|
|
112
|
-
);
|
|
113
194
|
|
|
114
195
|
const draft: ConsentDraftState = {
|
|
115
196
|
get isStale() {
|
|
116
197
|
return (
|
|
117
|
-
draftFingerprint !== null &&
|
|
118
|
-
|
|
198
|
+
(draftFingerprint !== null &&
|
|
199
|
+
(draftFingerprint !== snapshot.evaluationPolicy.choice.fingerprint ||
|
|
200
|
+
draftScope !==
|
|
201
|
+
(
|
|
202
|
+
snapshot.evaluationPolicy.choiceScope ??
|
|
203
|
+
snapshot.policyRule.scope
|
|
204
|
+
).join(','))) ||
|
|
205
|
+
(draftVendorSurface !== null &&
|
|
206
|
+
draftVendorSurface !== vendorSurface(snapshot))
|
|
119
207
|
);
|
|
120
208
|
},
|
|
121
209
|
reset() {
|
|
122
210
|
draftRevision += 1;
|
|
123
211
|
draftValues = {};
|
|
212
|
+
draftVendors = {};
|
|
124
213
|
draftFingerprint = null;
|
|
214
|
+
draftScope = null;
|
|
215
|
+
draftVendorSurface = null;
|
|
125
216
|
},
|
|
126
217
|
async save(categories) {
|
|
127
218
|
const revision = draftRevision;
|
|
@@ -129,59 +220,143 @@
|
|
|
129
220
|
const sequence = draftSaveSequence;
|
|
130
221
|
const current = kernel.getSnapshot();
|
|
131
222
|
if (
|
|
132
|
-
draftFingerprint !== null &&
|
|
133
|
-
|
|
223
|
+
(draftFingerprint !== null &&
|
|
224
|
+
(draftFingerprint !== current.evaluationPolicy.choice.fingerprint ||
|
|
225
|
+
draftScope !==
|
|
226
|
+
(
|
|
227
|
+
current.evaluationPolicy.choiceScope ?? current.policyRule.scope
|
|
228
|
+
).join(','))) ||
|
|
229
|
+
(draftVendorSurface !== null &&
|
|
230
|
+
draftVendorSurface !== vendorSurface(current))
|
|
134
231
|
) {
|
|
135
232
|
throw new Error(
|
|
136
233
|
'The policy changed. Review your preferences before saving.'
|
|
137
234
|
);
|
|
138
235
|
}
|
|
139
236
|
const { values } = draft;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
237
|
+
// Only the vendors the draft moved travel with the save, so an
|
|
238
|
+
// untouched vendor never renews its recorded confirmation time.
|
|
239
|
+
const seeded = seedVendors(current);
|
|
240
|
+
const moved: Record<string, boolean> = {};
|
|
241
|
+
for (const [id, granted] of Object.entries(draft.vendors)) {
|
|
242
|
+
if (seeded[id] !== granted) {
|
|
243
|
+
setOwn(moved, id, granted);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const result = await kernel.commands.save({
|
|
247
|
+
...Object.fromEntries(
|
|
248
|
+
(current.evaluationPolicy.choiceScope ?? current.policyRule.scope)
|
|
148
249
|
.filter(
|
|
149
250
|
(name) => categories === undefined || categories.includes(name)
|
|
150
251
|
)
|
|
151
252
|
.map((name) => [name, values[name]])
|
|
152
|
-
)
|
|
153
|
-
|
|
253
|
+
),
|
|
254
|
+
...(Object.keys(moved).length > 0 && { vendors: moved }),
|
|
255
|
+
});
|
|
154
256
|
if (!result.ok) {
|
|
155
257
|
throw new Error('Unable to save preferences.');
|
|
156
258
|
}
|
|
157
|
-
if (
|
|
259
|
+
if (sequence !== draftSaveSequence) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (revision === draftRevision) {
|
|
158
263
|
draft.reset();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// The visitor edited while the save was in flight. What was sent
|
|
267
|
+
// is the baseline now, so a submitted entry the visitor did not
|
|
268
|
+
// move again leaves the draft, or it would override a record
|
|
269
|
+
// another surface writes later; edits made after the submit stay.
|
|
270
|
+
const nextValues: Partial<ConsentState> = {};
|
|
271
|
+
for (const [name, staged] of Object.entries(draftValues)) {
|
|
272
|
+
if (values[name as OptionalConsentCategory] !== staged) {
|
|
273
|
+
nextValues[name as OptionalConsentCategory] = staged;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
draftValues = nextValues;
|
|
277
|
+
const nextVendors: Record<string, boolean> = {};
|
|
278
|
+
for (const [id, staged] of Object.entries(draftVendors)) {
|
|
279
|
+
if (moved[id] !== staged) {
|
|
280
|
+
setOwn(nextVendors, id, staged);
|
|
281
|
+
}
|
|
159
282
|
}
|
|
283
|
+
draftVendors = nextVendors;
|
|
284
|
+
settleDraft();
|
|
160
285
|
},
|
|
161
286
|
set(name, value) {
|
|
162
287
|
if (name === 'necessary') {
|
|
163
288
|
return;
|
|
164
289
|
}
|
|
290
|
+
const current = kernel.getSnapshot();
|
|
291
|
+
draftRevision += 1;
|
|
292
|
+
draftFingerprint ??= current.evaluationPolicy.choice.fingerprint;
|
|
293
|
+
draftScope ??= (
|
|
294
|
+
current.evaluationPolicy.choiceScope ?? current.policyRule.scope
|
|
295
|
+
).join(',');
|
|
296
|
+
// A category edit reviews the vendor rows too: a vendor declared
|
|
297
|
+
// under it later was not what the visitor saw.
|
|
298
|
+
draftVendorSurface ??= vendorSurface(current);
|
|
299
|
+
// Only moved categories are staged, like the vendor map.
|
|
300
|
+
const next: Partial<ConsentState> = {};
|
|
301
|
+
for (const [key, staged] of Object.entries(draftValues)) {
|
|
302
|
+
if (key !== name) {
|
|
303
|
+
next[key as OptionalConsentCategory] = staged;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (value !== baselineValue(current, name)) {
|
|
307
|
+
next[name] = value;
|
|
308
|
+
}
|
|
309
|
+
draftValues = next;
|
|
310
|
+
settleDraft();
|
|
311
|
+
},
|
|
312
|
+
setVendor(vendorId, granted) {
|
|
313
|
+
const current = kernel.getSnapshot();
|
|
314
|
+
if (!toggleableVendor(current, vendorId)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
165
317
|
draftRevision += 1;
|
|
166
|
-
draftFingerprint ??=
|
|
167
|
-
|
|
168
|
-
|
|
318
|
+
draftFingerprint ??= current.evaluationPolicy.choice.fingerprint;
|
|
319
|
+
draftScope ??= (
|
|
320
|
+
current.evaluationPolicy.choiceScope ?? current.policyRule.scope
|
|
321
|
+
).join(',');
|
|
322
|
+
draftVendorSurface ??= vendorSurface(current);
|
|
323
|
+
// Only moved vendors are staged. A vendor put back to its seeded
|
|
324
|
+
// value leaves the map, or the entry would outlive the seed and
|
|
325
|
+
// override a record another island writes later.
|
|
326
|
+
const next: Record<string, boolean> = {};
|
|
327
|
+
for (const [id, value] of Object.entries(draftVendors)) {
|
|
328
|
+
if (id !== vendorId) {
|
|
329
|
+
setOwn(next, id, value);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (granted !== seedVendors(current)[vendorId]) {
|
|
333
|
+
setOwn(next, vendorId, granted);
|
|
334
|
+
}
|
|
335
|
+
draftVendors = next;
|
|
336
|
+
settleDraft();
|
|
169
337
|
},
|
|
170
338
|
get values() {
|
|
171
339
|
return {
|
|
172
340
|
necessary: true,
|
|
173
341
|
...Object.fromEntries(
|
|
174
|
-
|
|
342
|
+
(
|
|
343
|
+
snapshot.evaluationPolicy.choiceScope ?? snapshot.policyRule.scope
|
|
344
|
+
).map((name) => [
|
|
175
345
|
name,
|
|
176
|
-
draftValues[name] ??
|
|
177
|
-
snapshot.explicitChoice?.categories[name]?.value ??
|
|
178
|
-
options.presentation?.preferences?.defaults?.[name] ??
|
|
179
|
-
(snapshot.policyRule.model === 'opt-out' ||
|
|
180
|
-
snapshot.policyRule.preselectedCategories.includes(name)),
|
|
346
|
+
draftValues[name] ?? baselineValue(snapshot, name),
|
|
181
347
|
])
|
|
182
348
|
),
|
|
183
349
|
};
|
|
184
350
|
},
|
|
351
|
+
get vendors() {
|
|
352
|
+
const seeded = seedVendors(snapshot);
|
|
353
|
+
for (const [id, granted] of Object.entries(draftVendors)) {
|
|
354
|
+
if (toggleableVendor(snapshot, id)) {
|
|
355
|
+
setOwn(seeded, id, granted);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return seeded;
|
|
359
|
+
},
|
|
185
360
|
};
|
|
186
361
|
|
|
187
362
|
const getIABState = function getIABState(): SvelteIABState | null {
|
|
@@ -229,10 +404,7 @@
|
|
|
229
404
|
clearRecords: () => runtime.clearRecords(),
|
|
230
405
|
getConsentCategories: () => [
|
|
231
406
|
'necessary',
|
|
232
|
-
...snapshot.policyRule.scope
|
|
233
|
-
(name) =>
|
|
234
|
-
!configuredCategories.length || configuredCategories.includes(name)
|
|
235
|
-
),
|
|
407
|
+
...(snapshot.evaluationPolicy.choiceScope ?? snapshot.policyRule.scope),
|
|
236
408
|
],
|
|
237
409
|
getDraft: () => draft,
|
|
238
410
|
getIAB: getIABState,
|
|
@@ -294,11 +466,12 @@
|
|
|
294
466
|
// removing the prop restores that rather than leaving the last pushed
|
|
295
467
|
// list in place. Only restored if this provider did the pushing: a
|
|
296
468
|
// borrowed runtime's categories belong to whoever owns it.
|
|
297
|
-
const initialCategories = untrack(() =>
|
|
469
|
+
const initialCategories = untrack(() => [
|
|
470
|
+
...(kernel.getSnapshot().consentCategories ?? []),
|
|
471
|
+
]);
|
|
298
472
|
let pushedCategories = false;
|
|
299
473
|
|
|
300
474
|
$effect(() => {
|
|
301
|
-
configuredCategories = consentCategoriesOption ?? initialCategories;
|
|
302
475
|
if (consentCategoriesOption) {
|
|
303
476
|
runtime.setConsentCategories(consentCategoriesOption);
|
|
304
477
|
pushedCategories = true;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
} from '@c15t/core';
|
|
7
7
|
import accordionStyles from '@c15t/ui/styles/components/accordion';
|
|
8
8
|
import managerStyles from '@c15t/ui/styles/components/consent-manager';
|
|
9
|
-
import
|
|
9
|
+
import switchStyles from '@c15t/ui/styles/components/switch';
|
|
10
10
|
import { getTextDirection, resolveTranslations } from '@c15t/ui/utils';
|
|
11
11
|
|
|
12
12
|
import { getConsentContext, getThemeContext } from '../context.svelte';
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
import ConsentButton from './action-button.svelte';
|
|
16
16
|
import Branding from './branding.svelte';
|
|
17
17
|
import PolicyActionsRenderer from './policy-actions-renderer.svelte';
|
|
18
|
-
|
|
19
|
-
const sw = switchVariants({ size: 'small' });
|
|
18
|
+
import VendorList from './vendor-list.svelte';
|
|
20
19
|
|
|
21
20
|
let {
|
|
22
21
|
hideBranding = true,
|
|
@@ -215,15 +214,12 @@
|
|
|
215
214
|
: undefined}
|
|
216
215
|
onclick={() => toggleConsent(consentType.name, !isChecked)}
|
|
217
216
|
disabled={isDisabled}
|
|
218
|
-
class={noStyle ? '' :
|
|
217
|
+
class={noStyle ? '' : switchStyles.root}
|
|
218
|
+
data-size={noStyle ? undefined : 'small'}
|
|
219
219
|
data-testid={`consent-widget-switch-${consentType.name}`}
|
|
220
220
|
>
|
|
221
|
-
<Switch.Control
|
|
222
|
-
class={noStyle ? '' :
|
|
223
|
-
>
|
|
224
|
-
<Switch.Thumb
|
|
225
|
-
class={noStyle ? '' : sw.thumb({ disabled: isDisabled })}
|
|
226
|
-
/>
|
|
221
|
+
<Switch.Control class={noStyle ? '' : switchStyles.track}>
|
|
222
|
+
<Switch.Thumb class={noStyle ? '' : switchStyles.thumb} />
|
|
227
223
|
</Switch.Control>
|
|
228
224
|
</Switch.Root>
|
|
229
225
|
</PreferenceItem.Control>
|
|
@@ -249,6 +245,10 @@
|
|
|
249
245
|
{translations.consentTypes[consentType.name]?.description ??
|
|
250
246
|
consentType.description ??
|
|
251
247
|
''}
|
|
248
|
+
<VendorList
|
|
249
|
+
category={consentType.name}
|
|
250
|
+
{noStyle}
|
|
251
|
+
/>
|
|
252
252
|
</PreferenceItem.Content>
|
|
253
253
|
</PreferenceItem.Root>
|
|
254
254
|
{/each}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Vendor cards nested inside one category's accordion content. Each is a
|
|
4
|
+
* collapsed card of its own that opens for its description and privacy
|
|
5
|
+
* policy link, with a switch on the header. The markup, class names,
|
|
6
|
+
* `data-slot`s and ARIA attributes mirror the React
|
|
7
|
+
* `ConsentWidgetVendorList` so the cross-framework parity runner sees
|
|
8
|
+
* identical DOM.
|
|
9
|
+
*/
|
|
10
|
+
import type { AllConsentNames, ResolvedVendor } from '@c15t/core';
|
|
11
|
+
import { defaultTranslationConfig } from '@c15t/core';
|
|
12
|
+
import switchStyles from '@c15t/ui/styles/components/switch';
|
|
13
|
+
import vendorListStyles from '@c15t/ui/styles/components/vendor-list';
|
|
14
|
+
import { resolveTranslations } from '@c15t/ui/utils';
|
|
15
|
+
|
|
16
|
+
import { getConsentContext } from '../context.svelte';
|
|
17
|
+
import { PreferenceItem, Switch } from '../primitives';
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
category,
|
|
21
|
+
noStyle = false,
|
|
22
|
+
}: {
|
|
23
|
+
/** Category whose vendors to list. */
|
|
24
|
+
category: AllConsentNames;
|
|
25
|
+
/** Drop the built-in styling. */
|
|
26
|
+
noStyle?: boolean;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
const consent = getConsentContext();
|
|
30
|
+
const uid = $props.id();
|
|
31
|
+
|
|
32
|
+
const DEFAULT_COPY = {
|
|
33
|
+
disabledByCategory: 'Turn on this category to choose vendors.',
|
|
34
|
+
privacyPolicy: 'Privacy policy',
|
|
35
|
+
switchLabel: 'Allow {vendor}',
|
|
36
|
+
title: 'Vendors ({count})',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const copy = $derived({
|
|
40
|
+
...DEFAULT_COPY,
|
|
41
|
+
...resolveTranslations(
|
|
42
|
+
consent.state.translationConfig,
|
|
43
|
+
defaultTranslationConfig
|
|
44
|
+
).consentManagerDialog.vendors,
|
|
45
|
+
});
|
|
46
|
+
const vendors = $derived(consent.state.getDisplayedVendors(category));
|
|
47
|
+
const categoryOn = $derived(
|
|
48
|
+
consent.state.selectedConsents[category] === true
|
|
49
|
+
);
|
|
50
|
+
const styles = $derived(noStyle ? undefined : vendorListStyles);
|
|
51
|
+
const title = $derived(copy.title.replace('{count}', String(vendors.length)));
|
|
52
|
+
|
|
53
|
+
// A set, not an object: a vendor id is any slug, and one such as
|
|
54
|
+
// `constructor` would read an inherited member off a plain object as open.
|
|
55
|
+
let openItems = $state<ReadonlySet<string>>(new Set());
|
|
56
|
+
const toggleOpen = (id: string) => {
|
|
57
|
+
const next = new Set(openItems);
|
|
58
|
+
if (next.has(id)) {
|
|
59
|
+
next.delete(id);
|
|
60
|
+
} else {
|
|
61
|
+
next.add(id);
|
|
62
|
+
}
|
|
63
|
+
openItems = next;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const vendorName = (vendor: ResolvedVendor) => vendor.name ?? vendor.id;
|
|
67
|
+
const hasDetails = (vendor: ResolvedVendor) =>
|
|
68
|
+
Boolean(vendor.description || vendor.privacyPolicyUrl);
|
|
69
|
+
// A `disabled` vendor is presented without a toggle: the kernel ignores
|
|
70
|
+
// grants for it, so a switch would only mislead.
|
|
71
|
+
const toggleable = (vendor: ResolvedVendor) => vendor.disabled !== true;
|
|
72
|
+
const isChecked = (vendor: ResolvedVendor) =>
|
|
73
|
+
consent.state.selectedVendors[vendor.id] ?? true;
|
|
74
|
+
const labelId = (vendor: ResolvedVendor) =>
|
|
75
|
+
`${uid}vendor-${category}-${vendor.id}`;
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
{#if vendors.length > 0}
|
|
79
|
+
<section
|
|
80
|
+
class={styles?.root}
|
|
81
|
+
aria-label={title}
|
|
82
|
+
data-testid={`consent-widget-vendor-list-${category}`}
|
|
83
|
+
>
|
|
84
|
+
{#if !categoryOn}
|
|
85
|
+
<p
|
|
86
|
+
class={styles?.hint}
|
|
87
|
+
data-testid={`consent-widget-vendor-hint-${category}`}
|
|
88
|
+
>
|
|
89
|
+
{copy.disabledByCategory}
|
|
90
|
+
</p>
|
|
91
|
+
{/if}
|
|
92
|
+
{#each vendors as vendor (vendor.id)}
|
|
93
|
+
{@const open = openItems.has(vendor.id)}
|
|
94
|
+
{@const details = hasDetails(vendor)}
|
|
95
|
+
{@const checked = isChecked(vendor)}
|
|
96
|
+
<PreferenceItem.Root
|
|
97
|
+
class={styles?.item}
|
|
98
|
+
disabled={!details}
|
|
99
|
+
{noStyle}
|
|
100
|
+
{open}
|
|
101
|
+
data-testid={`consent-widget-vendor-item-${category}-${vendor.id}`}
|
|
102
|
+
>
|
|
103
|
+
<div class={styles?.header}>
|
|
104
|
+
<PreferenceItem.Trigger
|
|
105
|
+
class={styles?.trigger}
|
|
106
|
+
onclick={() => {
|
|
107
|
+
toggleOpen(vendor.id);
|
|
108
|
+
}}
|
|
109
|
+
data-testid={`consent-widget-vendor-trigger-${category}-${vendor.id}`}
|
|
110
|
+
>
|
|
111
|
+
<PreferenceItem.Leading class={styles?.arrow}>
|
|
112
|
+
<svg
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
fill="none"
|
|
115
|
+
focusable="false"
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
stroke-linecap="round"
|
|
118
|
+
stroke-linejoin="round"
|
|
119
|
+
stroke-width="2"
|
|
120
|
+
viewBox="0 0 24 24"
|
|
121
|
+
>
|
|
122
|
+
<title>{open ? 'Close' : 'Open'}</title>
|
|
123
|
+
{#if open}
|
|
124
|
+
<path d="M5 12h14" />
|
|
125
|
+
{:else}
|
|
126
|
+
<path d="M5 12h14M12 5v14" />
|
|
127
|
+
{/if}
|
|
128
|
+
</svg>
|
|
129
|
+
</PreferenceItem.Leading>
|
|
130
|
+
<span
|
|
131
|
+
id={labelId(vendor)}
|
|
132
|
+
class={styles?.name}
|
|
133
|
+
data-testid={`consent-widget-vendor-name-${category}-${vendor.id}`}
|
|
134
|
+
>
|
|
135
|
+
{vendorName(vendor)}
|
|
136
|
+
</span>
|
|
137
|
+
</PreferenceItem.Trigger>
|
|
138
|
+
{#if toggleable(vendor)}
|
|
139
|
+
<div class={styles?.control}>
|
|
140
|
+
<Switch.Root
|
|
141
|
+
aria-label={copy.switchLabel.replace(
|
|
142
|
+
'{vendor}',
|
|
143
|
+
vendorName(vendor)
|
|
144
|
+
)}
|
|
145
|
+
aria-describedby={labelId(vendor)}
|
|
146
|
+
{checked}
|
|
147
|
+
class={noStyle ? undefined : switchStyles.root}
|
|
148
|
+
data-size={noStyle ? undefined : 'small'}
|
|
149
|
+
disabled={!categoryOn}
|
|
150
|
+
onclick={() =>
|
|
151
|
+
consent.state.setSelectedVendor(vendor.id, !checked)}
|
|
152
|
+
data-testid={`consent-widget-vendor-switch-${category}-${vendor.id}`}
|
|
153
|
+
>
|
|
154
|
+
<Switch.Control
|
|
155
|
+
class={noStyle ? undefined : switchStyles.track}
|
|
156
|
+
>
|
|
157
|
+
<Switch.Thumb
|
|
158
|
+
class={noStyle ? undefined : switchStyles.thumb}
|
|
159
|
+
/>
|
|
160
|
+
</Switch.Control>
|
|
161
|
+
</Switch.Root>
|
|
162
|
+
</div>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
{#if details}
|
|
166
|
+
<PreferenceItem.Content
|
|
167
|
+
class={styles?.content}
|
|
168
|
+
innerClassName={styles?.contentInner}
|
|
169
|
+
{noStyle}
|
|
170
|
+
data-testid={`consent-widget-vendor-content-${category}-${vendor.id}`}
|
|
171
|
+
>
|
|
172
|
+
{#if vendor.description}
|
|
173
|
+
<p class={styles?.description}>{vendor.description}</p>
|
|
174
|
+
{/if}
|
|
175
|
+
{#if vendor.privacyPolicyUrl}
|
|
176
|
+
<a
|
|
177
|
+
class={styles?.link}
|
|
178
|
+
href={vendor.privacyPolicyUrl}
|
|
179
|
+
rel="noopener noreferrer"
|
|
180
|
+
target="_blank"
|
|
181
|
+
>
|
|
182
|
+
{copy.privacyPolicy}
|
|
183
|
+
</a>
|
|
184
|
+
{/if}
|
|
185
|
+
</PreferenceItem.Content>
|
|
186
|
+
{/if}
|
|
187
|
+
</PreferenceItem.Root>
|
|
188
|
+
{/each}
|
|
189
|
+
</section>
|
|
190
|
+
{/if}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor cards nested inside one category's accordion content. Each is a
|
|
3
|
+
* collapsed card of its own that opens for its description and privacy
|
|
4
|
+
* policy link, with a switch on the header. The markup, class names,
|
|
5
|
+
* `data-slot`s and ARIA attributes mirror the React
|
|
6
|
+
* `ConsentWidgetVendorList` so the cross-framework parity runner sees
|
|
7
|
+
* identical DOM.
|
|
8
|
+
*/
|
|
9
|
+
import type { AllConsentNames } from '@c15t/core';
|
|
10
|
+
type $$ComponentProps = {
|
|
11
|
+
/** Category whose vendors to list. */
|
|
12
|
+
category: AllConsentNames;
|
|
13
|
+
/** Drop the built-in styling. */
|
|
14
|
+
noStyle?: boolean;
|
|
15
|
+
};
|
|
16
|
+
declare const VendorList: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type VendorList = ReturnType<typeof VendorList>;
|
|
18
|
+
export default VendorList;
|