srcdev-nuxt-components 9.4.0 → 9.4.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.
@@ -68,21 +68,21 @@ useGoogleAnalytics();
68
68
 
69
69
  ## CSS / styling
70
70
 
71
- Public tokens (all on `.cookie-consent-banner`, `var(--cookie-consent-banner-*, fallback)` pattern):
71
+ Public tokens (all on `.privacy-notice-banner`, `var(--privacy-notice-banner-*, fallback)` pattern):
72
72
 
73
73
  | Token | Default |
74
74
  |---|---|
75
- | `--cookie-consent-banner-z-index` | `999999` |
76
- | `--cookie-consent-banner-gutter` | `1.6rem` |
77
- | `--cookie-consent-banner-max-width` | `64rem` |
78
- | `--cookie-consent-banner-border-radius` | `0.8rem` |
79
- | `--cookie-consent-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
80
- | `--cookie-consent-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
81
- | `--cookie-consent-banner-transition-duration` | `200ms` |
75
+ | `--privacy-notice-banner-z-index` | `999999` |
76
+ | `--privacy-notice-banner-gutter` | `1.6rem` |
77
+ | `--privacy-notice-banner-max-width` | `64rem` |
78
+ | `--privacy-notice-banner-border-radius` | `0.8rem` |
79
+ | `--privacy-notice-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
80
+ | `--privacy-notice-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
81
+ | `--privacy-notice-banner-transition-duration` | `200ms` |
82
82
 
83
83
  ## Notes
84
84
 
85
- - **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".cookie-consent-banner")`, not `wrapper.find(...)`.
85
+ - **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".privacy-notice-banner")`, not `wrapper.find(...)`.
86
86
  - **No focus trap / backdrop** — this is a dismiss-by-decision banner, not a modal. It collapses via a `grid-template-rows` transition (same mechanic as `DisplayPrompt`) once `status` leaves `"unset"`, rather than unmounting.
87
87
  - **Only ever one instance** — `useCookieConsent()`'s underlying state is a module-scope singleton, so mounting the banner twice in one app just duplicates the UI, it doesn't create separate consent state.
88
88
  - To let a visitor change their mind later (e.g. from a cookie-policy page), call `useCookieConsent().rejectAll()` or clear the `cookie-consent` cookie — the banner reappears since `status` returns to `"unset"` only once the cookie is gone; `rejectAll()` itself sets it to `"denied"`, which keeps the banner hidden but stops GA. Expose a dedicated "reset my choice" affordance if you want the banner itself to resurface.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- `useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `cookie-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
5
+ `useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `privacy-notice-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
6
6
 
7
7
  **This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useCookieConsent.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
8
8
 
@@ -28,7 +28,7 @@ Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here f
28
28
  const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
29
29
 
30
30
  export function useCookieConsent() {
31
- const stored = useCookie<"granted" | "denied" | null>("cookie-consent", {
31
+ const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
32
32
  maxAge: 60 * 60 * 24 * 365,
33
33
  sameSite: "lax",
34
34
  default: () => null,
@@ -70,9 +70,9 @@ if (status.value === "unset") {
70
70
  }
71
71
  ```
72
72
 
73
- To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `cookie-consent` cookie to bring the banner back.
73
+ To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `privacy-notice-consent` cookie to bring the banner back.
74
74
 
75
75
  ## Notes
76
76
 
77
- - Cookie name `cookie-consent` is fixed by the layer, not configurable per app.
77
+ - Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
78
78
  - This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-google-analytics]] for the GA4 integration that consumes `trigger`.
@@ -60,9 +60,9 @@ export const useGoogleAnalytics = () => {
60
60
  return;
61
61
  }
62
62
 
63
- const { trigger } = useCookieConsent();
63
+ const { status, trigger } = useCookieConsent();
64
64
 
65
- useScriptGoogleAnalytics({
65
+ const { consent } = useScriptGoogleAnalytics({
66
66
  id,
67
67
  scriptOptions: { trigger },
68
68
  defaultConsent: {
@@ -72,13 +72,31 @@ export const useGoogleAnalytics = () => {
72
72
  analytics_storage: "denied",
73
73
  },
74
74
  });
75
+
76
+ // trigger only gates whether gtag.js *loads* — consent.update() is the separate API that
77
+ // actually reports the visitor's decision back to gtag.js for every hit it sends.
78
+ watch(
79
+ status,
80
+ (value) => {
81
+ if (value === "unset" || !consent) return;
82
+ const granted = value === "granted";
83
+ consent.update({
84
+ ad_storage: granted ? "granted" : "denied",
85
+ ad_user_data: granted ? "granted" : "denied",
86
+ ad_personalization: granted ? "granted" : "denied",
87
+ analytics_storage: granted ? "granted" : "denied",
88
+ });
89
+ },
90
+ { immediate: true }
91
+ );
75
92
  };
76
93
  ```
77
94
 
78
95
  ### Key rules
79
96
 
80
97
  - **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
81
- - **`defaultConsent` is all `"denied"`** — this is what makes the script Consent Mode v2 compliant: gtag.js can load (if triggered) but won't set cookies or send identifiable pings until `useCookieConsent().acceptAll()` calls `trigger.accept()`, which flips consent to granted via `@nuxt/scripts`' own consent-update wiring.
98
+ - **`defaultConsent` is all `"denied"`** — sets the initial Consent Mode v2 state before gtag.js has any real signal. `trigger` (passed to `scriptOptions.trigger`) only controls when the script *loads*; it does **not** itself update the Consent Mode signals. Without the `consent.update()` watcher, hits fire successfully once loaded but stay tagged as denied forever, which GA4 excludes from standard reporting — this was a real bug (hits visible in Network, "no data received" in the GA4 dashboard) until the watcher was added.
99
+ - The `watch(status, ..., { immediate: true })` also covers a returning visitor whose `cookie-consent` cookie already says "granted" on this page load, before any click happens.
82
100
  - Does not return anything — it's a side-effecting setup call, not a value composable. Read GA-related state (if ever needed) via `useCookieConsent()` instead.
83
101
 
84
102
  ## Notes
@@ -1,28 +1,28 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
3
  <div
4
- class="cookie-consent-banner"
4
+ class="privacy-notice-banner"
5
5
  :class="[{ closed: status !== 'unset' }, elementClasses]"
6
6
  :data-theme="resolved.theme"
7
- data-test-id="cookie-consent-banner"
7
+ data-test-id="privacy-notice-banner"
8
8
  >
9
- <div class="cookie-consent-banner-inner" role="region" :aria-label="ariaLabel">
10
- <div class="cookie-consent-banner-message">
9
+ <div class="privacy-notice-banner-inner" role="region" :aria-label="ariaLabel">
10
+ <div class="privacy-notice-banner-message">
11
11
  <slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
12
12
  </div>
13
- <div class="cookie-consent-banner-actions">
13
+ <div class="privacy-notice-banner-actions">
14
14
  <button
15
15
  type="button"
16
- class="cookie-consent-banner-reject"
17
- data-test-id="cookie-consent-banner-reject"
16
+ class="privacy-notice-banner-reject"
17
+ data-test-id="privacy-notice-banner-reject"
18
18
  @click="rejectAll()"
19
19
  >
20
20
  <slot name="rejectLabel">Reject</slot>
21
21
  </button>
22
22
  <button
23
23
  type="button"
24
- class="cookie-consent-banner-accept"
25
- data-test-id="cookie-consent-banner-accept"
24
+ class="privacy-notice-banner-accept"
25
+ data-test-id="privacy-notice-banner-accept"
26
26
  @click="acceptAll()"
27
27
  >
28
28
  <slot name="acceptLabel">Accept</slot>
@@ -57,17 +57,17 @@ const ariaLabel = "Cookie consent";
57
57
 
58
58
  <style lang="css">
59
59
  @layer components {
60
- .cookie-consent-banner {
60
+ .privacy-notice-banner {
61
61
  /* Matches DisplayToastProvider/DisplayDialog's z-index convention so
62
62
  this clears ordinary page chrome (and a consumer's sticky header) but
63
63
  still sits below an active modal dialog if one somehow overlaps. */
64
- --_z-index: var(--cookie-consent-banner-z-index, 999999);
65
- --_gutter: var(--cookie-consent-banner-gutter, 1.6rem);
66
- --_max-width: var(--cookie-consent-banner-max-width, 64rem);
67
- --_border-radius: var(--cookie-consent-banner-border-radius, 0.8rem);
68
- --_border: var(--cookie-consent-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
69
- --_background: var(--cookie-consent-banner-background, light-dark(var(--slate-00), var(--slate-10)));
70
- --_transition-duration: var(--cookie-consent-banner-transition-duration, 200ms);
64
+ --_z-index: var(--privacy-notice-banner-z-index, 999999);
65
+ --_gutter: var(--privacy-notice-banner-gutter, 1.6rem);
66
+ --_max-width: var(--privacy-notice-banner-max-width, 64rem);
67
+ --_border-radius: var(--privacy-notice-banner-border-radius, 0.8rem);
68
+ --_border: var(--privacy-notice-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
69
+ --_background: var(--privacy-notice-banner-background, light-dark(var(--slate-00), var(--slate-10)));
70
+ --_transition-duration: var(--privacy-notice-banner-transition-duration, 200ms);
71
71
 
72
72
  position: fixed;
73
73
  inset-inline: var(--_gutter);
@@ -87,7 +87,7 @@ const ariaLabel = "Cookie consent";
87
87
  pointer-events: none;
88
88
  }
89
89
 
90
- .cookie-consent-banner-inner {
90
+ .privacy-notice-banner-inner {
91
91
  overflow: hidden;
92
92
  display: flex;
93
93
  flex-wrap: wrap;
@@ -103,18 +103,18 @@ const ariaLabel = "Cookie consent";
103
103
  }
104
104
  }
105
105
 
106
- .cookie-consent-banner-message {
106
+ .privacy-notice-banner-message {
107
107
  flex: 1 1 24rem;
108
108
  }
109
109
 
110
- .cookie-consent-banner-actions {
110
+ .privacy-notice-banner-actions {
111
111
  display: flex;
112
112
  gap: 0.8rem;
113
113
  margin-inline-start: auto;
114
114
  }
115
115
 
116
- .cookie-consent-banner-reject,
117
- .cookie-consent-banner-accept {
116
+ .privacy-notice-banner-reject,
117
+ .privacy-notice-banner-accept {
118
118
  padding: 0.8rem 1.6rem;
119
119
  border-radius: 0.4rem;
120
120
  border: 0.1rem solid transparent;
@@ -124,7 +124,7 @@ const ariaLabel = "Cookie consent";
124
124
  background-color var(--_transition-duration);
125
125
  }
126
126
 
127
- .cookie-consent-banner-reject {
127
+ .privacy-notice-banner-reject {
128
128
  background-color: transparent;
129
129
  border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
130
130
 
@@ -134,7 +134,7 @@ const ariaLabel = "Cookie consent";
134
134
  }
135
135
  }
136
136
 
137
- .cookie-consent-banner-accept {
137
+ .privacy-notice-banner-accept {
138
138
  background-color: var(--theme-accent);
139
139
  color: light-dark(var(--slate-00), var(--slate-12));
140
140
 
@@ -29,18 +29,18 @@ export default {
29
29
  // reloads. Each story below has "Show banner again" (resets state
30
30
  // in-place) and "Delete cookie" (clears document.cookie directly, for
31
31
  // verifying the cookie itself is actually gone, e.g. in devtools).
32
- component: "Reads/writes a real 'cookie-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
32
+ component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
33
33
  },
34
34
  },
35
35
  },
36
36
  } as Meta<typeof StorybookComponent>;
37
37
 
38
- // Resets the same "cookie-consent" cookie useCookieConsent() reads, so the
38
+ // Resets the same "privacy-notice-consent" cookie useCookieConsent() reads, so the
39
39
  // banner's status goes back to "unset" and reappears without a page reload
40
40
  // (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
41
41
  // itself reads).
42
42
  function useResetConsent() {
43
- const consentCookie = useCookie<"granted" | "denied" | null>("cookie-consent");
43
+ const consentCookie = useCookie<"granted" | "denied" | null>("privacy-notice-consent");
44
44
  const showAgain = () => {
45
45
  consentCookie.value = null;
46
46
  };
@@ -48,7 +48,7 @@ function useResetConsent() {
48
48
  // rather than just the in-memory ref, so devtools/Application tab reflects
49
49
  // it too, not only the story's live re-render.
50
50
  const deleteCookie = () => {
51
- document.cookie = "cookie-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
51
+ document.cookie = "privacy-notice-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
52
52
  consentCookie.value = null;
53
53
  };
54
54
  return { showAgain, deleteCookie };
@@ -23,15 +23,15 @@ mockNuxtImport("useCookieConsent", () => () => ({
23
23
  }));
24
24
 
25
25
  function banner() {
26
- return document.querySelector(".cookie-consent-banner");
26
+ return document.querySelector(".privacy-notice-banner");
27
27
  }
28
28
 
29
29
  function acceptButton() {
30
- return document.querySelector("[data-test-id='cookie-consent-banner-accept']") as HTMLElement;
30
+ return document.querySelector("[data-test-id='privacy-notice-banner-accept']") as HTMLElement;
31
31
  }
32
32
 
33
33
  function rejectButton() {
34
- return document.querySelector("[data-test-id='cookie-consent-banner-reject']") as HTMLElement;
34
+ return document.querySelector("[data-test-id='privacy-notice-banner-reject']") as HTMLElement;
35
35
  }
36
36
 
37
37
  describe("CookieConsentBanner", () => {
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { nextTick, ref } from "vue";
3
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+
5
+ const { useRuntimeConfigMock, updateSpy, useScriptGoogleAnalyticsMock } = vi.hoisted(() => ({
6
+ useRuntimeConfigMock: vi.fn(() => ({ public: { googleAnalytics: { id: "" } }, app: { baseURL: "/" } })),
7
+ updateSpy: vi.fn(),
8
+ useScriptGoogleAnalyticsMock: vi.fn(() => ({ consent: { update: updateSpy, default: vi.fn() } })),
9
+ }));
10
+
11
+ const statusRef = ref<"unset" | "granted" | "denied">("unset");
12
+ const trigger = { consented: ref(false), accept: vi.fn(), revoke: vi.fn() };
13
+
14
+ mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
15
+ mockNuxtImport("useScriptGoogleAnalytics", () => useScriptGoogleAnalyticsMock);
16
+ mockNuxtImport("useCookieConsent", () => () => ({ status: statusRef, trigger }));
17
+
18
+ const { useGoogleAnalytics } = await import("../useGoogleAnalytics");
19
+
20
+ describe("useGoogleAnalytics", () => {
21
+ beforeEach(() => {
22
+ statusRef.value = "unset";
23
+ updateSpy.mockClear();
24
+ useScriptGoogleAnalyticsMock.mockClear();
25
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "" } }, app: { baseURL: "/" } });
26
+ });
27
+
28
+ it("warns and does nothing when googleAnalytics.id is unconfigured", () => {
29
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
30
+ useGoogleAnalytics();
31
+ expect(warnSpy).toHaveBeenCalledWith("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
32
+ expect(useScriptGoogleAnalyticsMock).not.toHaveBeenCalled();
33
+ warnSpy.mockRestore();
34
+ });
35
+
36
+ it("calls useScriptGoogleAnalytics with the configured id and defaultConsent denied", () => {
37
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
38
+ useGoogleAnalytics();
39
+ expect(useScriptGoogleAnalyticsMock).toHaveBeenCalledWith(
40
+ expect.objectContaining({
41
+ id: "G-TEST123",
42
+ defaultConsent: {
43
+ ad_storage: "denied",
44
+ ad_user_data: "denied",
45
+ ad_personalization: "denied",
46
+ analytics_storage: "denied",
47
+ },
48
+ })
49
+ );
50
+ });
51
+
52
+ it("does not call consent.update() while status is 'unset'", () => {
53
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
54
+ useGoogleAnalytics();
55
+ expect(updateSpy).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("calls consent.update() with all signals granted once status becomes 'granted'", async () => {
59
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
60
+ useGoogleAnalytics();
61
+ statusRef.value = "granted";
62
+ await nextTick();
63
+ expect(updateSpy).toHaveBeenCalledWith({
64
+ ad_storage: "granted",
65
+ ad_user_data: "granted",
66
+ ad_personalization: "granted",
67
+ analytics_storage: "granted",
68
+ });
69
+ });
70
+
71
+ it("calls consent.update() with all signals denied when status becomes 'denied'", async () => {
72
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
73
+ useGoogleAnalytics();
74
+ statusRef.value = "denied";
75
+ await nextTick();
76
+ expect(updateSpy).toHaveBeenCalledWith({
77
+ ad_storage: "denied",
78
+ ad_user_data: "denied",
79
+ ad_personalization: "denied",
80
+ analytics_storage: "denied",
81
+ });
82
+ });
83
+
84
+ it("immediately reports consent.update() for a returning visitor already granted", () => {
85
+ useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
86
+ statusRef.value = "granted";
87
+ useGoogleAnalytics();
88
+ expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({ analytics_storage: "granted" }));
89
+ });
90
+ });
@@ -18,7 +18,7 @@ export function useCookieConsent() {
18
18
  // Persists the decision across visits. Read fresh on every call so this
19
19
  // stays request-safe during SSR (Nuxt dedupes useCookie() by key within a
20
20
  // request, so this is cheap to call repeatedly).
21
- const stored = useCookie<"granted" | "denied" | null>("cookie-consent", {
21
+ const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
22
22
  maxAge: 60 * 60 * 24 * 365,
23
23
  sameSite: "lax",
24
24
  default: () => null,
@@ -11,9 +11,9 @@ export const useGoogleAnalytics = () => {
11
11
  return;
12
12
  }
13
13
 
14
- const { trigger } = useCookieConsent();
14
+ const { status, trigger } = useCookieConsent();
15
15
 
16
- useScriptGoogleAnalytics({
16
+ const { consent } = useScriptGoogleAnalytics({
17
17
  id,
18
18
  scriptOptions: { trigger },
19
19
  defaultConsent: {
@@ -23,4 +23,26 @@ export const useGoogleAnalytics = () => {
23
23
  analytics_storage: "denied",
24
24
  },
25
25
  });
26
+
27
+ // `trigger` above only gates whether gtag.js *loads* — it does not itself flip the Consent
28
+ // Mode v2 signals gtag.js reports alongside every hit. Without this, hits fire successfully
29
+ // (visible in Network) but stay tagged with defaultConsent's "denied" state forever, which
30
+ // GA4 excludes from standard reporting — confirmed via a real hit's gcs=G100/pscdl=denied
31
+ // query params still showing after accepting, 2026-09-06. `consent.update()` is the separate
32
+ // API that actually reports the visitor's decision back to gtag.js; `immediate: true` also
33
+ // covers a returning visitor whose cookie already says "granted" on this page load.
34
+ watch(
35
+ status,
36
+ (value) => {
37
+ if (value === "unset" || !consent) return;
38
+ const granted = value === "granted";
39
+ consent.update({
40
+ ad_storage: granted ? "granted" : "denied",
41
+ ad_user_data: granted ? "granted" : "denied",
42
+ ad_personalization: granted ? "granted" : "denied",
43
+ analytics_storage: granted ? "granted" : "denied",
44
+ });
45
+ },
46
+ { immediate: true }
47
+ );
26
48
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.4.0",
4
+ "version": "9.4.2",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",