non-spooky-react-cookie 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,837 @@
1
+ "use client";
2
+ import { a as writePreferences, d as BUILT_IN_LANGUAGES, f as getBuiltInTexts, i as removePreferences, l as localStorageAdapter, o as createBothStorage, p as resolveTexts, r as readPreferences, s as createCookieStorage, t as DEFAULT_STORAGE_KEY, u as resolveStorage } from "./storage-WH-kuxkg.js";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/integrations/global-privacy-control.ts
6
+ /**
7
+ * Global Privacy Control (https://w3c.github.io/gpc/) is a browser-level
8
+ * "do not sell or share my data" signal. When the visitor turns it on, the
9
+ * browser sends `Sec-GPC: 1` with every request and exposes
10
+ * `navigator.globalPrivacyControl === true` to scripts; when it is off the
11
+ * header is absent and the property is `false` (or missing entirely in
12
+ * browsers without native support, where extensions may define it).
13
+ *
14
+ * Returns `true` only for an active signal. Safe to call on the server.
15
+ */
16
+ function readGlobalPrivacyControl() {
17
+ if (typeof navigator === "undefined") return false;
18
+ return navigator.globalPrivacyControl === true;
19
+ }
20
+ //#endregion
21
+ //#region src/integrations/google-tracker.ts
22
+ function ensureGtag() {
23
+ if (typeof window === "undefined") return null;
24
+ window.dataLayer = window.dataLayer ?? [];
25
+ window.gtag = window.gtag ?? function gtag() {
26
+ window.dataLayer?.push(arguments);
27
+ };
28
+ return window.gtag;
29
+ }
30
+ /**
31
+ * Initializes Google's consent mode with everything denied.
32
+ * Call this before any Google tag loads.
33
+ */
34
+ function initGoogleTracker() {
35
+ const gtag = ensureGtag();
36
+ if (!gtag) return;
37
+ gtag("consent", "default", {
38
+ ad_storage: "denied",
39
+ ad_user_data: "denied",
40
+ ad_personalization: "denied",
41
+ analytics_storage: "denied"
42
+ });
43
+ }
44
+ /**
45
+ * Updates Google's consent mode based on the stored preferences.
46
+ *
47
+ * Without `categories`, the category ids `analytics` / `marketing` in the
48
+ * accepted map drive the signals. With `categories`, a category grants its
49
+ * signals when the category OR any of its fine-grained items is accepted,
50
+ * so item-level consent (e.g. "only Google Ads") is respected.
51
+ */
52
+ function updateGoogleTracker(state, categories) {
53
+ const gtag = ensureGtag();
54
+ if (!gtag) return;
55
+ const toValue = (id) => {
56
+ const category = categories?.find((candidate) => candidate.id === id);
57
+ return Boolean(state.accepted[id]) || Boolean(category?.items?.some((item) => state.accepted[item.id])) ? "granted" : "denied";
58
+ };
59
+ const analytics = toValue("analytics");
60
+ const marketing = toValue("marketing");
61
+ gtag("consent", "update", {
62
+ analytics_storage: analytics,
63
+ ad_storage: marketing,
64
+ ad_user_data: marketing,
65
+ ad_personalization: marketing
66
+ });
67
+ }
68
+ //#endregion
69
+ //#region src/integrations/script-runtime.ts
70
+ const runtimes = /* @__PURE__ */ new Map();
71
+ function getEntry(id) {
72
+ let entry = runtimes.get(id);
73
+ if (!entry) {
74
+ entry = {
75
+ status: "blocked",
76
+ listeners: /* @__PURE__ */ new Set()
77
+ };
78
+ runtimes.set(id, entry);
79
+ }
80
+ return entry;
81
+ }
82
+ /**
83
+ * Subscribe a listener to status changes for `id`.
84
+ * Returns an unsubscribe function. Safe to call on the server
85
+ * (returns a no-op) so `useSyncExternalStore` never crashes during SSR.
86
+ */
87
+ function subscribeScript(id, notify) {
88
+ if (typeof window === "undefined") return () => {};
89
+ getEntry(id).listeners.add(notify);
90
+ return () => {
91
+ getEntry(id).listeners.delete(notify);
92
+ };
93
+ }
94
+ /** Primitive snapshot for `useSyncExternalStore` (must be stable). */
95
+ function getScriptStatus(id) {
96
+ return getEntry(id).status;
97
+ }
98
+ /** The last error for `id`, if any. */
99
+ function getScriptError(id) {
100
+ return getEntry(id).error;
101
+ }
102
+ /**
103
+ * Sets the status (and error, if any) for `id` and notifies subscribers.
104
+ * The error is always replaced, so a later `loaded` clears an old failure.
105
+ */
106
+ function setScriptStatus(id, status, error) {
107
+ const entry = getEntry(id);
108
+ entry.status = status;
109
+ entry.error = error;
110
+ entry.listeners.forEach((listener) => {
111
+ try {
112
+ listener();
113
+ } catch {}
114
+ });
115
+ }
116
+ //#endregion
117
+ //#region src/integrations/script-loader.ts
118
+ /** Runs a consumer callback; its errors must never break consent enforcement. */
119
+ function safeCall(fn) {
120
+ try {
121
+ fn?.();
122
+ } catch {}
123
+ }
124
+ function findScriptElement(id) {
125
+ const element = document.getElementById(id);
126
+ return element instanceof HTMLScriptElement ? element : null;
127
+ }
128
+ function loadScript({ id, src, attrs, children, async: asyncFlag, defer, onLoad, onError }) {
129
+ if (typeof document === "undefined") return null;
130
+ const existing = findScriptElement(id);
131
+ if (existing) return existing;
132
+ const script = document.createElement("script");
133
+ script.id = id;
134
+ if (defer && !asyncFlag) script.defer = true;
135
+ else script.async = true;
136
+ if (src) script.src = src;
137
+ if (children) script.text = children;
138
+ if (onLoad) script.addEventListener("load", onLoad);
139
+ if (onError) script.addEventListener("error", onError);
140
+ Object.entries(attrs ?? {}).forEach(([key, value]) => {
141
+ script.setAttribute(key, value);
142
+ });
143
+ document.head.appendChild(script);
144
+ return script;
145
+ }
146
+ /**
147
+ * Removes the script element with `id` and runs the optional
148
+ * `cleanup` function.
149
+ *
150
+ * Caveat: this does NOT undo cookies or network requests the script
151
+ * already made — use `cleanup` for integration-specific teardown.
152
+ */
153
+ function unloadScript(id, cleanup) {
154
+ if (typeof document === "undefined") return;
155
+ findScriptElement(id)?.remove();
156
+ safeCall(cleanup);
157
+ }
158
+ /**
159
+ * Ensures the script under `id` is loaded, driving the runtime store.
160
+ *
161
+ * - Already in the DOM → status `loaded` (dedup, `onLoad` not re-fired).
162
+ * - Not in the DOM → status `loading`, then `loaded`/`error` when the
163
+ * element settles. `def.onLoad` / `def.onError` run (wrapped so a throw
164
+ * never breaks consent enforcement).
165
+ */
166
+ function ensureScript(id, def) {
167
+ if (typeof document === "undefined") return;
168
+ if (findScriptElement(id)) {
169
+ setScriptStatus(id, "loaded");
170
+ return;
171
+ }
172
+ const handleLoad = () => {
173
+ setScriptStatus(id, "loaded");
174
+ safeCall(def.onLoad);
175
+ };
176
+ setScriptStatus(id, "loading");
177
+ if (loadScript({
178
+ id,
179
+ src: def.src,
180
+ attrs: def.attrs,
181
+ children: def.children,
182
+ async: def.async,
183
+ defer: def.defer,
184
+ onLoad: handleLoad,
185
+ onError: () => {
186
+ setScriptStatus(id, "error", /* @__PURE__ */ new Error(`Script "${id}" failed to load.`));
187
+ safeCall(def.onError);
188
+ }
189
+ }) && !def.src) handleLoad();
190
+ }
191
+ /**
192
+ * Removes the script under `id` from the DOM (running `cleanup`) and
193
+ * resets its status back to `blocked`. A script that is already blocked
194
+ * and not in the DOM is left alone, so `cleanup` runs only after a real load.
195
+ */
196
+ function removeScript(id, cleanup) {
197
+ if (typeof document === "undefined") return;
198
+ if (getScriptStatus(id) === "blocked" && !findScriptElement(id)) return;
199
+ unloadScript(id, cleanup);
200
+ setScriptStatus(id, "blocked");
201
+ }
202
+ //#endregion
203
+ //#region src/CookieBannerConfigurationProvider.tsx
204
+ const DEFAULT_VERSION = "1";
205
+ const defaultCategories = [
206
+ {
207
+ id: "necessary",
208
+ required: true
209
+ },
210
+ { id: "preferences" },
211
+ { id: "analytics" },
212
+ { id: "marketing" }
213
+ ];
214
+ /** Maps the object-map `config` prop to the internal category array. */
215
+ function configToCategories(config) {
216
+ return Object.entries(config.categories).map(([id, category]) => ({
217
+ id,
218
+ title: category.name,
219
+ description: category.description,
220
+ required: category.required,
221
+ items: category.items ? Object.entries(category.items).map(([itemId, item]) => ({
222
+ id: itemId,
223
+ title: item.name,
224
+ description: item.description
225
+ })) : void 0
226
+ }));
227
+ }
228
+ const CookieBannerContext = createContext(null);
229
+ /**
230
+ * Builds a complete state. Required categories are always on; optional
231
+ * categories and every item follow `acceptOptional`.
232
+ */
233
+ function buildState(version, categories, acceptOptional) {
234
+ const accepted = {};
235
+ categories.forEach((category) => {
236
+ accepted[category.id] = Boolean(category.required) || acceptOptional;
237
+ category.items?.forEach((item) => {
238
+ accepted[item.id] = acceptOptional;
239
+ });
240
+ });
241
+ return {
242
+ version,
243
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
244
+ accepted
245
+ };
246
+ }
247
+ const themeVariables = {
248
+ primaryColor: "--nsr-primary",
249
+ primaryTextColor: "--nsr-primary-text",
250
+ primaryHoverColor: "--nsr-primary-hover",
251
+ secondaryColor: "--nsr-secondary",
252
+ secondaryTextColor: "--nsr-secondary-text",
253
+ accentColor: "--nsr-accent",
254
+ surfaceColor: "--nsr-surface",
255
+ surfaceMutedColor: "--nsr-surface-muted",
256
+ textColor: "--nsr-text",
257
+ mutedTextColor: "--nsr-muted",
258
+ borderColor: "--nsr-border",
259
+ ringColor: "--nsr-ring",
260
+ switchOffColor: "--nsr-switch-off",
261
+ switchThumbColor: "--nsr-switch-thumb",
262
+ backdropColor: "--nsr-backdrop"
263
+ };
264
+ /** The attribute that scopes a provider's theme rules to its elements. */
265
+ const THEME_ATTRIBUTE = "data-nsr-theme";
266
+ /**
267
+ * Keeps a palette value safe to embed in a stylesheet: a value is a single
268
+ * CSS color, so it never needs a declaration or block terminator.
269
+ */
270
+ function sanitizeCssValue(value) {
271
+ return value.replace(/[;{}<>]/g, "").trim();
272
+ }
273
+ /** `[--nsr-x, value]` pairs for the set entries of a palette. */
274
+ function themeEntries(theme) {
275
+ return Object.entries(themeVariables).flatMap(([key, variable]) => {
276
+ const value = theme[key];
277
+ return value ? [[variable, sanitizeCssValue(value)]] : [];
278
+ });
279
+ }
280
+ /** Resolves the theme palette into CSS custom properties (set values only). */
281
+ function themeToStyle(theme) {
282
+ return Object.fromEntries(themeEntries(theme));
283
+ }
284
+ /**
285
+ * Builds the scoped stylesheet for one provider. `theme` applies to every
286
+ * element carrying the provider's theme attribute; `darkTheme` applies to the
287
+ * same elements under a `.dark` / `[data-theme="dark"]` ancestor. Returns an
288
+ * empty string when neither palette sets anything, so nothing is rendered.
289
+ */
290
+ function buildThemeCss(id, theme, darkTheme) {
291
+ const scope = `[${THEME_ATTRIBUTE}="${id.replace(/["\\]/g, "")}"]`;
292
+ const block = (entries) => entries.map(([variable, value]) => ` ${variable}: ${value};`).join("\n");
293
+ const light = themeEntries(theme);
294
+ const dark = themeEntries(darkTheme);
295
+ const rules = [];
296
+ if (light.length > 0) rules.push(`${scope} {\n${block(light)}\n}`);
297
+ if (dark.length > 0) rules.push(`:is(.dark, [data-theme="dark"]) ${scope} {\n${block(dark)}\n}`);
298
+ return rules.join("\n");
299
+ }
300
+ function CookieBannerConfigurationProvider({ children, config, scripts, language = "en", texts: textOverrides, theme = {}, darkTheme = {}, components = {}, storageKey = DEFAULT_STORAGE_KEY, storage = "localStorage", cookieOptions, initialPreferences, version = DEFAULT_VERSION, googleConsentMode = false, windowJustDont = true, respectGlobalPrivacyControl = true, onDecision }) {
301
+ const cookieOptionsKey = JSON.stringify(cookieOptions ?? null);
302
+ const store = useMemo(() => resolveStorage(storage, JSON.parse(cookieOptionsKey) ?? void 0), [storage, cookieOptionsKey]);
303
+ const categories = useMemo(() => config ? configToCategories(config) : defaultCategories, [config]);
304
+ const texts = useMemo(() => resolveTexts(language, textOverrides), [language, textOverrides]);
305
+ const themeId = useId();
306
+ const themeKey = JSON.stringify(theme);
307
+ const darkThemeKey = JSON.stringify(darkTheme);
308
+ const themeStyle = useMemo(() => themeToStyle(JSON.parse(themeKey)), [themeKey]);
309
+ const themeCss = useMemo(() => buildThemeCss(themeId, JSON.parse(themeKey), JSON.parse(darkThemeKey)), [
310
+ darkThemeKey,
311
+ themeId,
312
+ themeKey
313
+ ]);
314
+ const themeAttributes = useMemo(() => ({ [THEME_ATTRIBUTE]: themeId }), [themeId]);
315
+ const itemToCategory = useMemo(() => {
316
+ const owners = {};
317
+ categories.forEach((category) => {
318
+ category.items?.forEach((item) => {
319
+ owners[item.id] = category.id;
320
+ });
321
+ });
322
+ return owners;
323
+ }, [categories]);
324
+ const restoredInitial = initialPreferences?.version === version ? initialPreferences : null;
325
+ const [loaded, setLoaded] = useState(initialPreferences !== void 0);
326
+ const [hasDecision, setHasDecision] = useState(restoredInitial !== null);
327
+ const [globalPrivacyControl, setGlobalPrivacyControl] = useState(false);
328
+ const [settingsOpen, setSettingsOpen] = useState(false);
329
+ const [state, setState] = useState(() => restoredInitial ?? buildState(version, categories, false));
330
+ const syncGoogle = useCallback((next) => {
331
+ if (googleConsentMode) updateGoogleTracker(next, categories);
332
+ }, [categories, googleConsentMode]);
333
+ const onDecisionRef = useRef(onDecision);
334
+ useEffect(() => {
335
+ onDecisionRef.current = onDecision;
336
+ }, [onDecision]);
337
+ useEffect(() => {
338
+ if (googleConsentMode) initGoogleTracker();
339
+ const stored = readPreferences(store, storageKey);
340
+ const restored = stored?.version === version ? stored : null;
341
+ const gpc = respectGlobalPrivacyControl && readGlobalPrivacyControl();
342
+ const next = restored ?? buildState(version, categories, false);
343
+ const decidedByGpc = restored === null && gpc;
344
+ setState(next);
345
+ setHasDecision(restored !== null || decidedByGpc);
346
+ setGlobalPrivacyControl(gpc);
347
+ syncGoogle(next);
348
+ setLoaded(true);
349
+ if (decidedByGpc) onDecisionRef.current?.(next);
350
+ }, [
351
+ categories,
352
+ googleConsentMode,
353
+ respectGlobalPrivacyControl,
354
+ storageKey,
355
+ store,
356
+ syncGoogle,
357
+ version
358
+ ]);
359
+ useEffect(() => {
360
+ const entries = Object.entries(scripts ?? {});
361
+ return () => {
362
+ for (const [id, def] of entries) removeScript(id, def.cleanup);
363
+ };
364
+ }, [scripts]);
365
+ useEffect(() => {
366
+ if (!loaded) return;
367
+ Object.entries(scripts ?? {}).forEach(([id, def]) => {
368
+ if (state.accepted[def.category]) ensureScript(id, def);
369
+ else removeScript(id, def.cleanup);
370
+ });
371
+ }, [
372
+ loaded,
373
+ scripts,
374
+ state.accepted
375
+ ]);
376
+ const persist = useCallback((next) => {
377
+ setState(next);
378
+ setHasDecision(true);
379
+ setSettingsOpen(false);
380
+ writePreferences(store, storageKey, next);
381
+ syncGoogle(next);
382
+ onDecision?.(next);
383
+ }, [
384
+ onDecision,
385
+ storageKey,
386
+ store,
387
+ syncGoogle
388
+ ]);
389
+ const acceptAll = useCallback(() => persist(buildState(version, categories, true)), [
390
+ categories,
391
+ persist,
392
+ version
393
+ ]);
394
+ const rejectAll = useCallback(() => persist(buildState(version, categories, false)), [
395
+ categories,
396
+ persist,
397
+ version
398
+ ]);
399
+ const savePreferences = useCallback((partial) => persist({
400
+ version,
401
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
402
+ accepted: {
403
+ ...state.accepted,
404
+ ...partial.accepted
405
+ }
406
+ }), [
407
+ persist,
408
+ state.accepted,
409
+ version
410
+ ]);
411
+ const resetPreferences = useCallback(() => {
412
+ const initial = buildState(version, categories, false);
413
+ removePreferences(store, storageKey);
414
+ setState(initial);
415
+ setHasDecision(false);
416
+ setSettingsOpen(false);
417
+ syncGoogle(initial);
418
+ onDecision?.(initial);
419
+ }, [
420
+ categories,
421
+ onDecision,
422
+ storageKey,
423
+ store,
424
+ syncGoogle,
425
+ version
426
+ ]);
427
+ const openSettings = useCallback(() => setSettingsOpen(true), []);
428
+ const closeSettings = useCallback(() => setSettingsOpen(false), []);
429
+ const isAllowed = useCallback((id) => Boolean(state.accepted[id]), [state.accepted]);
430
+ /**
431
+ * Resolves the display title/description for a category or item,
432
+ * preferring the config values, then the texts, then the id.
433
+ */
434
+ const resolveLabel = useCallback((id, config) => {
435
+ const parent = itemToCategory[id];
436
+ const source = parent ? texts.categories[parent]?.items?.[id] : texts.categories[id];
437
+ return {
438
+ title: config?.title ?? source?.title ?? id,
439
+ description: config?.description ?? source?.description ?? ""
440
+ };
441
+ }, [itemToCategory, texts]);
442
+ const value = useMemo(() => ({
443
+ loaded,
444
+ hasDecision,
445
+ showBanner: loaded && !hasDecision,
446
+ globalPrivacyControl,
447
+ settingsOpen,
448
+ preferences: state,
449
+ texts,
450
+ categories,
451
+ scripts: scripts ?? {},
452
+ theme,
453
+ darkTheme,
454
+ components,
455
+ themeStyle,
456
+ themeAttributes,
457
+ acceptAll,
458
+ rejectAll,
459
+ savePreferences,
460
+ resetPreferences,
461
+ openSettings,
462
+ closeSettings,
463
+ isAllowed,
464
+ resolveLabel
465
+ }), [
466
+ acceptAll,
467
+ categories,
468
+ closeSettings,
469
+ components,
470
+ darkTheme,
471
+ globalPrivacyControl,
472
+ hasDecision,
473
+ isAllowed,
474
+ loaded,
475
+ openSettings,
476
+ rejectAll,
477
+ resetPreferences,
478
+ resolveLabel,
479
+ savePreferences,
480
+ scripts,
481
+ settingsOpen,
482
+ state,
483
+ texts,
484
+ theme,
485
+ themeAttributes,
486
+ themeStyle
487
+ ]);
488
+ useEffect(() => {
489
+ if (!windowJustDont || typeof window === "undefined") return;
490
+ const w = window;
491
+ if (typeof w.justDont === "function") console.warn("[non-spooky-react-cookie] window.justDont already exists; the cookie banner will overwrite it.");
492
+ w.justDont = rejectAll;
493
+ return () => {
494
+ if (w.justDont === rejectAll) delete w.justDont;
495
+ };
496
+ }, [rejectAll, windowJustDont]);
497
+ return /* @__PURE__ */ jsxs(CookieBannerContext.Provider, {
498
+ value,
499
+ children: [themeCss ? /* @__PURE__ */ jsx("style", {
500
+ "data-nsr-theme-style": themeId,
501
+ children: themeCss
502
+ }) : null, children]
503
+ });
504
+ }
505
+ //#endregion
506
+ //#region src/hooks/usePreferences.ts
507
+ /**
508
+ * Access the cookie banner state and actions from anywhere
509
+ * inside a CookieBannerConfigurationProvider.
510
+ */
511
+ function usePreferences() {
512
+ const context = useContext(CookieBannerContext);
513
+ if (!context) throw new Error("usePreferences must be used within a CookieBannerConfigurationProvider");
514
+ return context;
515
+ }
516
+ //#endregion
517
+ //#region src/ui.tsx
518
+ function cn(...classes) {
519
+ return classes.filter(Boolean).join(" ");
520
+ }
521
+ /**
522
+ * Default Button.
523
+ * Colors come from the `--nsr-*` variables: defaults in `styles.css`,
524
+ * overrides from the provider's `theme` prop.
525
+ */
526
+ function Button({ className, variant = "secondary", ...props }) {
527
+ return /* @__PURE__ */ jsx("button", {
528
+ className: cn("nsr-button", `nsr-button--${variant}`, className),
529
+ ...props
530
+ });
531
+ }
532
+ /**
533
+ * Default Switch.
534
+ * The "on" color follows the theme's primary color.
535
+ */
536
+ function Switch({ checked, disabled, onCheckedChange, "aria-label": ariaLabel }) {
537
+ return /* @__PURE__ */ jsx("button", {
538
+ type: "button",
539
+ role: "switch",
540
+ "aria-checked": checked,
541
+ "aria-label": ariaLabel,
542
+ disabled,
543
+ onClick: () => onCheckedChange(!checked),
544
+ className: "nsr-switch",
545
+ children: /* @__PURE__ */ jsx("span", { className: "nsr-switch__thumb" })
546
+ });
547
+ }
548
+ /**
549
+ * Default Collapsible.
550
+ * A disclosure that hides its children until the trigger is pressed.
551
+ * Works controlled (`open` + `onOpenChange`) or self-managed (`defaultOpen`).
552
+ * The chevron follows the theme's primary color.
553
+ */
554
+ function Collapsible({ children, count, label, open, onOpenChange, defaultOpen, className, contentClassName }) {
555
+ const regionId = useId();
556
+ const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
557
+ const isOpen = open ?? internalOpen;
558
+ const toggle = () => {
559
+ const next = !isOpen;
560
+ if (open === void 0) setInternalOpen(next);
561
+ onOpenChange?.(next);
562
+ };
563
+ return /* @__PURE__ */ jsxs("div", {
564
+ className: cn("nsr-collapsible", isOpen && "nsr-collapsible--open"),
565
+ children: [/* @__PURE__ */ jsxs("button", {
566
+ type: "button",
567
+ "aria-controls": regionId,
568
+ "aria-expanded": isOpen,
569
+ className: cn("nsr-collapsible__trigger", className),
570
+ onClick: toggle,
571
+ children: [/* @__PURE__ */ jsxs("span", {
572
+ className: "nsr-collapsible__label",
573
+ children: [
574
+ label,
575
+ " (",
576
+ count,
577
+ ")"
578
+ ]
579
+ }), /* @__PURE__ */ jsx("svg", {
580
+ className: "nsr-collapsible__chevron",
581
+ fill: "none",
582
+ stroke: "currentColor",
583
+ strokeWidth: 2,
584
+ strokeLinecap: "round",
585
+ strokeLinejoin: "round",
586
+ viewBox: "0 0 24 24",
587
+ "aria-hidden": "true",
588
+ children: /* @__PURE__ */ jsx("path", { d: "M6 9l6 6 6-6" })
589
+ })]
590
+ }), isOpen ? /* @__PURE__ */ jsx("div", {
591
+ className: cn("nsr-collapsible__content", contentClassName),
592
+ id: regionId,
593
+ children
594
+ }) : null]
595
+ });
596
+ }
597
+ //#endregion
598
+ //#region src/CookieSettingsDialog.tsx
599
+ /** Renders nothing while closed; the open dialog mounts fresh each time. */
600
+ function CookieSettingsDialog(props) {
601
+ const { settingsOpen } = usePreferences();
602
+ return settingsOpen ? /* @__PURE__ */ jsx(OpenSettingsDialog, { ...props }) : null;
603
+ }
604
+ function OpenSettingsDialog({ className, overlayClassName, contentClassName, headerClassName, bodyClassName, footerClassName, categoryCardClassName, itemClassName, buttonClassName, components: ownComponents }) {
605
+ const { categories, closeSettings, components: providerComponents, preferences, resolveLabel, savePreferences, texts, themeAttributes } = usePreferences();
606
+ const dialogRef = useRef(null);
607
+ const [draft, setDraft] = useState(preferences.accepted);
608
+ useEffect(() => {
609
+ const dialog = dialogRef.current;
610
+ if (dialog && !dialog.open && typeof dialog.showModal === "function") dialog.showModal();
611
+ }, []);
612
+ const components = {
613
+ ...providerComponents,
614
+ ...ownComponents
615
+ };
616
+ const ButtonComponent = components.Button ?? Button;
617
+ const SwitchComponent = components.Switch ?? Switch;
618
+ const CollapsibleComponent = components.Collapsible ?? Collapsible;
619
+ const setAccepted = (ids, value) => setDraft((current) => {
620
+ const next = { ...current };
621
+ for (const id of ids) next[id] = value;
622
+ return next;
623
+ });
624
+ const handleCancel = (event) => {
625
+ event.preventDefault();
626
+ closeSettings();
627
+ };
628
+ return /* @__PURE__ */ jsxs("dialog", {
629
+ "aria-labelledby": "nsr-settings-title",
630
+ className: cn("nsr-settings-dialog", className),
631
+ onCancel: handleCancel,
632
+ ref: dialogRef,
633
+ ...themeAttributes,
634
+ children: [/* @__PURE__ */ jsx("button", {
635
+ "aria-label": texts.dialog.close,
636
+ className: cn("nsr-dialog__overlay", overlayClassName),
637
+ onClick: closeSettings,
638
+ tabIndex: -1,
639
+ type: "button"
640
+ }), /* @__PURE__ */ jsxs("div", {
641
+ className: cn("nsr-dialog__panel", contentClassName),
642
+ children: [
643
+ /* @__PURE__ */ jsxs("div", {
644
+ className: cn("nsr-dialog__header", headerClassName),
645
+ children: [/* @__PURE__ */ jsx("h2", {
646
+ id: "nsr-settings-title",
647
+ className: "nsr-dialog__title",
648
+ children: texts.dialog.title
649
+ }), /* @__PURE__ */ jsx("p", {
650
+ className: "nsr-dialog__description",
651
+ children: texts.dialog.description
652
+ })]
653
+ }),
654
+ /* @__PURE__ */ jsx("div", {
655
+ className: cn("nsr-dialog__body", bodyClassName),
656
+ children: categories.map((category) => {
657
+ const { title, description } = resolveLabel(category.id, category);
658
+ const required = Boolean(category.required);
659
+ const items = category.items ?? [];
660
+ return /* @__PURE__ */ jsxs("section", {
661
+ className: cn("nsr-category", required && "nsr-category--required", categoryCardClassName),
662
+ children: [/* @__PURE__ */ jsxs("div", {
663
+ className: "nsr-category__header",
664
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
665
+ className: "nsr-category__title",
666
+ children: title
667
+ }), description ? /* @__PURE__ */ jsx("p", {
668
+ className: "nsr-category__description",
669
+ children: description
670
+ }) : null] }), /* @__PURE__ */ jsx(SwitchComponent, {
671
+ "aria-label": title,
672
+ checked: Boolean(draft[category.id]),
673
+ disabled: required,
674
+ onCheckedChange: (value) => setAccepted([category.id, ...items.map((item) => item.id)], value)
675
+ })]
676
+ }), items.length > 0 ? /* @__PURE__ */ jsx(CollapsibleComponent, {
677
+ count: items.length,
678
+ label: texts.dialog.itemsLabel,
679
+ contentClassName: "nsr-category__items",
680
+ children: items.map((item) => {
681
+ const itemLabel = resolveLabel(item.id, item);
682
+ return /* @__PURE__ */ jsxs("div", {
683
+ className: cn("nsr-item", itemClassName),
684
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h4", {
685
+ className: "nsr-item__title",
686
+ children: itemLabel.title
687
+ }), itemLabel.description ? /* @__PURE__ */ jsx("p", {
688
+ className: "nsr-item__description",
689
+ children: itemLabel.description
690
+ }) : null] }), /* @__PURE__ */ jsx(SwitchComponent, {
691
+ "aria-label": itemLabel.title,
692
+ checked: Boolean(draft[item.id]),
693
+ disabled: required,
694
+ onCheckedChange: (value) => setAccepted([item.id], value)
695
+ })]
696
+ }, item.id);
697
+ })
698
+ }) : null]
699
+ }, category.id);
700
+ })
701
+ }),
702
+ /* @__PURE__ */ jsxs("div", {
703
+ className: cn("nsr-dialog__footer", footerClassName),
704
+ children: [/* @__PURE__ */ jsx(ButtonComponent, {
705
+ className: cn("nsr-dialog__button", buttonClassName),
706
+ onClick: closeSettings,
707
+ type: "button",
708
+ variant: "ghost",
709
+ children: texts.dialog.close
710
+ }), /* @__PURE__ */ jsx(ButtonComponent, {
711
+ className: cn("nsr-dialog__button", buttonClassName),
712
+ onClick: () => savePreferences({ accepted: draft }),
713
+ type: "button",
714
+ variant: "primary",
715
+ children: texts.dialog.save
716
+ })]
717
+ })
718
+ ]
719
+ })]
720
+ });
721
+ }
722
+ //#endregion
723
+ //#region src/CookieBanner.tsx
724
+ function CookieBanner({ policyUrl, className, contentClassName, titleClassName, descriptionClassName, actionsClassName, buttonClassName, components, dialogProps }) {
725
+ const { acceptAll, components: providerComponents, openSettings, rejectAll, showBanner, texts, themeAttributes } = usePreferences();
726
+ const ButtonComponent = components?.Button ?? providerComponents.Button ?? Button;
727
+ return /* @__PURE__ */ jsxs(Fragment, { children: [showBanner ? /* @__PURE__ */ jsx("section", {
728
+ "aria-label": texts.banner.title,
729
+ className: cn("nsr-banner", className),
730
+ ...themeAttributes,
731
+ children: /* @__PURE__ */ jsxs("div", {
732
+ className: cn("nsr-banner__card", contentClassName),
733
+ children: [/* @__PURE__ */ jsxs("div", {
734
+ className: "nsr-banner__text",
735
+ children: [/* @__PURE__ */ jsx("h2", {
736
+ className: cn("nsr-banner__title", titleClassName),
737
+ children: texts.banner.title
738
+ }), /* @__PURE__ */ jsxs("p", {
739
+ className: cn("nsr-banner__description", descriptionClassName),
740
+ children: [texts.banner.description, policyUrl ? /* @__PURE__ */ jsxs(Fragment, { children: [" ", /* @__PURE__ */ jsx("a", {
741
+ className: "nsr-banner__link",
742
+ href: policyUrl,
743
+ children: texts.banner.policyLink
744
+ })] }) : null]
745
+ })]
746
+ }), /* @__PURE__ */ jsxs("div", {
747
+ className: cn("nsr-banner__actions", actionsClassName),
748
+ children: [
749
+ /* @__PURE__ */ jsx(ButtonComponent, {
750
+ className: buttonClassName,
751
+ onClick: rejectAll,
752
+ type: "button",
753
+ variant: "secondary",
754
+ children: texts.banner.rejectAll
755
+ }),
756
+ /* @__PURE__ */ jsx(ButtonComponent, {
757
+ className: buttonClassName,
758
+ onClick: openSettings,
759
+ type: "button",
760
+ variant: "secondary",
761
+ children: texts.banner.settings
762
+ }),
763
+ /* @__PURE__ */ jsx(ButtonComponent, {
764
+ className: buttonClassName,
765
+ onClick: acceptAll,
766
+ type: "button",
767
+ variant: "primary",
768
+ children: texts.banner.acceptAll
769
+ })
770
+ ]
771
+ })]
772
+ })
773
+ }) : null, /* @__PURE__ */ jsx(CookieSettingsDialog, {
774
+ ...dialogProps,
775
+ components: {
776
+ ...components,
777
+ ...dialogProps?.components
778
+ }
779
+ })] });
780
+ }
781
+ //#endregion
782
+ //#region src/CookieSettingsLink.tsx
783
+ /**
784
+ * A small link (e.g. in a footer) that opens the cookie settings dialog.
785
+ * Renders your children, or the built-in "Cookie settings" text.
786
+ */
787
+ function CookieSettingsLink({ children, className, type = "button", onClick, ...props }) {
788
+ const { openSettings, texts, themeAttributes } = usePreferences();
789
+ return /* @__PURE__ */ jsx("button", {
790
+ className: cn("nsr-settings-link", className),
791
+ ...themeAttributes,
792
+ onClick: (event) => {
793
+ openSettings();
794
+ onClick?.(event);
795
+ },
796
+ type,
797
+ ...props,
798
+ children: children ?? texts.footerLink
799
+ });
800
+ }
801
+ //#endregion
802
+ //#region src/hooks/useConsentScript.ts
803
+ /**
804
+ * Reactive, consent-gated load status for a script declared in the
805
+ * provider's `scripts` map.
806
+ *
807
+ * The status is gated on the script's `category`: denied → `blocked`;
808
+ * granted → the provider drives `loading` → `loaded`/`error`. The hook never
809
+ * loads anything itself — the provider is the single enforcement point.
810
+ *
811
+ * Must be rendered inside a `CookieBannerConfigurationProvider`.
812
+ */
813
+ function useConsentScript(id) {
814
+ const context = useContext(CookieBannerContext);
815
+ const def = context?.scripts[id];
816
+ const subscribe = useCallback((notify) => subscribeScript(id, notify), [id]);
817
+ const runtimeStatus = useSyncExternalStore(subscribe, () => getScriptStatus(id), () => "blocked");
818
+ const runtimeError = useSyncExternalStore(subscribe, () => getScriptError(id), () => void 0);
819
+ if (!context) return {
820
+ status: "error",
821
+ error: /* @__PURE__ */ new Error(`useConsentScript("${id}") must be used within a CookieBannerConfigurationProvider.`)
822
+ };
823
+ if (!def) return {
824
+ status: "error",
825
+ error: /* @__PURE__ */ new Error(`useConsentScript("${id}"): script is not declared in the provider's \`scripts\` map.`)
826
+ };
827
+ if (!context.isAllowed(def.category)) return { status: "blocked" };
828
+ const status = runtimeStatus === "blocked" ? "loading" : runtimeStatus;
829
+ return {
830
+ status,
831
+ error: status === "error" ? runtimeError : void 0
832
+ };
833
+ }
834
+ //#endregion
835
+ export { BUILT_IN_LANGUAGES, Button, Collapsible, CookieBanner, CookieBannerConfigurationProvider, CookieSettingsDialog, CookieSettingsLink, DEFAULT_STORAGE_KEY, Switch, THEME_ATTRIBUTE, cn, createBothStorage, createCookieStorage, getBuiltInTexts, initGoogleTracker, localStorageAdapter, updateGoogleTracker, useConsentScript, usePreferences };
836
+
837
+ //# sourceMappingURL=index.js.map