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