@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.
Files changed (34) hide show
  1. package/AGENTS.md +3 -0
  2. package/dist/components/{frame.svelte.d.ts → consent-gate.svelte.d.ts} +3 -3
  3. package/dist/components/iab-purpose-item.svelte +3 -1
  4. package/dist/components/iab-stack-item.svelte +1 -1
  5. package/dist/components/iab-vendor-list.svelte +2 -0
  6. package/dist/components/manager-provider.svelte +207 -34
  7. package/dist/components/preferences.svelte +10 -10
  8. package/dist/components/vendor-list.svelte +190 -0
  9. package/dist/components/vendor-list.svelte.d.ts +18 -0
  10. package/dist/context.svelte.d.ts +36 -2
  11. package/dist/context.svelte.js +19 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.js +3 -1
  14. package/dist/kit/routes.js +26 -1
  15. package/dist/kit/types.d.ts +10 -0
  16. package/dist/primitives/preference-item/index.d.ts +1 -0
  17. package/dist/primitives/preference-item/preference-item-content.svelte +17 -3
  18. package/dist/primitives/preference-item/preference-item-content.svelte.d.ts +10 -0
  19. package/dist/version.d.ts +1 -1
  20. package/dist/version.js +1 -1
  21. package/docs/README.md +3 -0
  22. package/docs/frameworks/sveltekit/quickstart.md +4 -0
  23. package/docs/guides/deployment-modes.md +12 -0
  24. package/docs/integrations/building-integrations.md +5 -0
  25. package/docs/integrations/clear-on-revocation.md +167 -0
  26. package/docs/integrations/cloudflare-zaraz.md +399 -0
  27. package/docs/integrations/google-maps.md +20 -20
  28. package/docs/integrations/granular-consent.md +208 -0
  29. package/docs/integrations/overview.md +5 -4
  30. package/docs/integrations/youtube.md +26 -21
  31. package/docs/upgrade-v3.md +47 -0
  32. package/package.json +7 -7
  33. package/readme.json +0 -36
  34. /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 Frame: import("svelte").Component<$$ComponentProps, {}, "">;
11
- type Frame = ReturnType<typeof Frame>;
12
- export default Frame;
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}
@@ -120,7 +120,7 @@
120
120
  </PreferenceItem.Control>
121
121
  </div>
122
122
 
123
- <PreferenceItem.Content>
123
+ <PreferenceItem.Content {noStyle}>
124
124
  <div class={noStyle ? '' : styles.stackDescription || ''}>
125
125
  <p>{stack.description}</p>
126
126
  </div>
@@ -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
- draftFingerprint !== snapshot.evaluationPolicy.choice.fingerprint
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
- draftFingerprint !== current.evaluationPolicy.choice.fingerprint
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
- const result = await kernel.commands.save(
141
- Object.fromEntries(
142
- current.policyRule.scope
143
- .filter(
144
- (name) =>
145
- configuredCategories.length === 0 ||
146
- configuredCategories.includes(name)
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 (revision === draftRevision && sequence === draftSaveSequence) {
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
- kernel.getSnapshot().evaluationPolicy.choice.fingerprint;
168
- draftValues = { ...draftValues, [name]: value };
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
- snapshot.policyRule.scope.map((name) => [
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.filter(
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(() => runtime.consentCategories);
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 { switchVariants } from '@c15t/ui/styles/primitives';
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 ? '' : sw.root()}
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 ? '' : sw.track({ disabled: isDisabled })}
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;