srcdev-nuxt-components 9.3.13 → 9.4.1

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.
@@ -0,0 +1,88 @@
1
+ # CookieConsentBanner
2
+
3
+ ## Overview
4
+
5
+ Fixed, non-modal banner shown while cookie consent is undecided (`useCookieConsent().status === 'unset'`). Accept/Reject buttons call `acceptAll()`/`rejectAll()` on [[composable-cookie-consent]] directly — no `v-model`, no emits to wire up. Pair with [[composable-google-analytics]] (or any other consent-gated script) which reads the same consent state.
6
+
7
+ **Location**: `app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue`
8
+
9
+ **Types**: `~/types/components` — `CookieConsentBannerProps`, `CookieConsentStatus`
10
+
11
+ ---
12
+
13
+ ## Props
14
+
15
+ | Prop | Type | Default | Notes |
16
+ |---|---|---|---|
17
+ | `theme` | `SemanticTheme` | `"info"` | Colours the banner's accent border and accept button via `--theme-accent`. |
18
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element. |
19
+
20
+ ### app.config defaults
21
+
22
+ ```ts
23
+ // Consumer's app.config.ts
24
+ export default defineAppConfig({
25
+ srcdev: {
26
+ cookieConsentBanner: {
27
+ theme: "info",
28
+ },
29
+ },
30
+ })
31
+ ```
32
+
33
+ Resolution chain: **prop → app.config → hardcoded fallback**.
34
+
35
+ ## Slots
36
+
37
+ | Slot | Purpose | Default |
38
+ |---|---|---|
39
+ | `message` | The consent message body | "This site uses cookies to understand how it's used. You can accept or reject them." |
40
+ | `acceptLabel` | Accept button text | "Accept" |
41
+ | `rejectLabel` | Reject button text | "Reject" |
42
+
43
+ Per [[feedback_i18n_required]] in consuming apps, always fill these slots with `t()`-sourced copy rather than relying on the English defaults.
44
+
45
+ ## Basic usage
46
+
47
+ Register once, in the app's default layout, alongside `DisplayToastProvider` and a call to `useGoogleAnalytics()`:
48
+
49
+ ```vue
50
+ <!-- layouts/default.vue -->
51
+ <script setup lang="ts">
52
+ const { t } = useI18n();
53
+ useGoogleAnalytics();
54
+ </script>
55
+
56
+ <template>
57
+ <div class="page-layout">
58
+ <slot />
59
+ <DisplayToastProvider position="top" alignment="right" :max-visible="3" />
60
+ <CookieConsentBanner>
61
+ <template #message>{{ t("global.cookieConsent.message") }}</template>
62
+ <template #acceptLabel>{{ t("global.cookieConsent.accept") }}</template>
63
+ <template #rejectLabel>{{ t("global.cookieConsent.reject") }}</template>
64
+ </CookieConsentBanner>
65
+ </div>
66
+ </template>
67
+ ```
68
+
69
+ ## CSS / styling
70
+
71
+ Public tokens (all on `.privacy-notice-banner`, `var(--privacy-notice-banner-*, fallback)` pattern):
72
+
73
+ | Token | Default |
74
+ |---|---|
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
+
83
+ ## Notes
84
+
85
+ - **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".privacy-notice-banner")`, not `wrapper.find(...)`.
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
+ - **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
+ - 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.
@@ -0,0 +1,78 @@
1
+ # useCookieConsent Composable
2
+
3
+ ## Overview
4
+
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
+
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
+
9
+ ## Prerequisites
10
+
11
+ - `@nuxt/scripts` — the layer itself declares this as a dependency and registers it in its own `modules` array, so it's present in any app that extends this layer. No action needed in the consuming app, but be aware `useScriptTriggerConsent`/`useScriptGoogleAnalytics` are auto-imported from that module, not this layer.
12
+
13
+ ## Setup in the consuming app
14
+
15
+ ### 1. No import needed
16
+
17
+ `useCookieConsent` is auto-imported by Nuxt from the layer. Use it directly in `<script setup>` or any composable without an explicit import.
18
+
19
+ ### 2. Render the banner
20
+
21
+ Pair it with [[cookie-consent-banner]] (`CookieConsentBanner.vue`), registered once in the app's default layout — see that component's skill doc for placement and copy slots.
22
+
23
+ ## Composable reference
24
+
25
+ Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
26
+
27
+ ```ts
28
+ const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
29
+
30
+ export function useCookieConsent() {
31
+ const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
32
+ maxAge: 60 * 60 * 24 * 365,
33
+ sameSite: "lax",
34
+ default: () => null,
35
+ });
36
+
37
+ if (stored.value === "granted" && !consentTrigger.consented.value) {
38
+ consentTrigger.accept();
39
+ }
40
+
41
+ const status = computed(() => stored.value ?? "unset");
42
+
43
+ const acceptAll = () => {
44
+ stored.value = "granted";
45
+ consentTrigger.accept();
46
+ };
47
+
48
+ const rejectAll = () => {
49
+ stored.value = "denied";
50
+ consentTrigger.revoke();
51
+ };
52
+
53
+ return { status, acceptAll, rejectAll, trigger: consentTrigger };
54
+ }
55
+ ```
56
+
57
+ ### Key rules
58
+
59
+ - **`useScriptTriggerConsent()` is called once at module scope**, mirroring `@nuxt/scripts`' own documented pattern — it's a single shared gate for the app's lifetime, not a fresh instance per call-site. `useCookie()` is read fresh inside the function body on every call instead, which stays SSR-request-safe (Nuxt dedupes `useCookie()` by key within a single request).
60
+ - **`status` is the public read API.** `trigger` is exposed only so `useGoogleAnalytics` (or another consent-gated script composable) can pass it straight into `scriptOptions.trigger` — don't read/mutate `trigger` directly from app code, use `status`/`acceptAll`/`rejectAll`.
61
+ - A prior "granted" cookie is replayed into the trigger on init, since the in-memory trigger resets on every full page load but the cookie doesn't.
62
+
63
+ ## Usage
64
+
65
+ ```ts
66
+ const { status, acceptAll, rejectAll } = useCookieConsent();
67
+
68
+ if (status.value === "unset") {
69
+ // show the banner
70
+ }
71
+ ```
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 `privacy-notice-consent` cookie to bring the banner back.
74
+
75
+ ## Notes
76
+
77
+ - Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
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`.
@@ -0,0 +1,87 @@
1
+ # useGoogleAnalytics Composable
2
+
3
+ ## Overview
4
+
5
+ `useGoogleAnalytics` loads Google Analytics 4 (`gtag.js`) via `@nuxt/scripts`' `useScriptGoogleAnalytics()`, gated behind [[composable-cookie-consent]]'s consent state and Google Consent Mode v2's `defaultConsent`. The script does not fetch, and no analytics cookie is set, until the visitor accepts via [[cookie-consent-banner]] (or your own call to `useCookieConsent().acceptAll()`).
6
+
7
+ **This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useGoogleAnalytics.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
8
+
9
+ ## Prerequisites
10
+
11
+ - `NUXT_PUBLIC_GOOGLE_ANALYTICS_ID` env var set to your GA4 measurement ID (`G-XXXXXXXXXX`).
12
+ - `@nuxt/scripts` — already a layer dependency, registered in the layer's own `modules` array. Nothing to add in the consuming app.
13
+ - The app's CSP (if using `nuxt-security`) must allow `https://www.googletagmanager.com` in `script-src`/`connect-src` and `https://www.google-analytics.com` in `connect-src`, or the script will be blocked once consent is granted.
14
+
15
+ ## Setup in the consuming app
16
+
17
+ ### 1. Runtime config
18
+
19
+ ```ts
20
+ // nuxt.config.ts
21
+ runtimeConfig: {
22
+ public: {
23
+ googleAnalytics: {
24
+ id: "", // NUXT_PUBLIC_GOOGLE_ANALYTICS_ID
25
+ },
26
+ },
27
+ },
28
+ ```
29
+
30
+ ### 2. Call it once
31
+
32
+ Call `useGoogleAnalytics()` once, near the app root — e.g. in the default layout's `<script setup>`, alongside where [[cookie-consent-banner]] is registered:
33
+
34
+ ```vue
35
+ <script setup lang="ts">
36
+ useGoogleAnalytics();
37
+ </script>
38
+
39
+ <template>
40
+ <div class="page-layout">
41
+ <slot />
42
+ <CookieConsentBanner />
43
+ </div>
44
+ </template>
45
+ ```
46
+
47
+ It no-ops (with a console warning) if `googleAnalytics.id` is unset — safe to call unconditionally in every environment, including local dev without a real measurement ID.
48
+
49
+ ## Composable reference
50
+
51
+ Source lives at `app/composables/useGoogleAnalytics.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
52
+
53
+ ```ts
54
+ export const useGoogleAnalytics = () => {
55
+ const config = useRuntimeConfig();
56
+ const id = config.public.googleAnalytics?.id;
57
+
58
+ if (!id) {
59
+ console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
60
+ return;
61
+ }
62
+
63
+ const { trigger } = useCookieConsent();
64
+
65
+ useScriptGoogleAnalytics({
66
+ id,
67
+ scriptOptions: { trigger },
68
+ defaultConsent: {
69
+ ad_storage: "denied",
70
+ ad_user_data: "denied",
71
+ ad_personalization: "denied",
72
+ analytics_storage: "denied",
73
+ },
74
+ });
75
+ };
76
+ ```
77
+
78
+ ### Key rules
79
+
80
+ - **`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.
82
+ - 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
+
84
+ ## Notes
85
+
86
+ - Only loads GA4 (`gtag.js`) — no other Google tags (Ads, Tag Manager container, etc.). Extend `defaultConsent`/add a second `useScript*` call if those are needed later.
87
+ - The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
@@ -59,6 +59,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
59
59
  ├── composable-dialog-controls.md — useDialogControls: single-call setup with config object, openDialog/closeDialog API, confirm/cancel callbacks
60
60
  ├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
61
61
  ├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
62
+ ├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
63
+ ├── composable-google-analytics.md — useGoogleAnalytics: GA4 via @nuxt/scripts' useScriptGoogleAnalytics, Consent Mode v2 defaultConsent, gated on useCookieConsent
62
64
  └── components/
63
65
  ├── alert-content-inner.md — AlertContentInner: shared icon/body/dismiss molecule under AlertContent + AlertMaskedContent; app.config icon map (alertContent.icons + dismissIcon) covers all consumers
64
66
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
@@ -106,7 +108,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
106
108
  ├── expanding-panel-classic.md — ExpandingPanelClassic: grid-template-rows animation (no Baseline-2025 dependency), same API as ExpandingPanel, cross-browser animation parity trade-off
107
109
  ├── site-header.md — SiteHeader: PageRow + SkipLinks + ResponsiveHeader composition, #branding/#secondaryNavigation slots, dual styleClassPassthrough hooks
108
110
  ├── responsive-header.md — ResponsiveHeader: overflow-collapsing adaptive nav, measurement-pipeline gotchas (unsized icons, vw font-size drift), full CSS token API
109
- └── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
111
+ ├── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
112
+ └── cookie-consent-banner.md — CookieConsentBanner: fixed non-modal Accept/Reject banner driven by useCookieConsent, message/acceptLabel/rejectLabel slots, CSS token API
110
113
  ```
111
114
 
112
115
  ## Skill file template
package/app/app.config.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { SemanticTheme, DisplayPromptTheme, DisplayToastTheme, DisplayToastPosition, DisplayToastAlignment } from "./types/components";
1
+ import type {
2
+ SemanticTheme,
3
+ DisplayPromptTheme,
4
+ DisplayToastTheme,
5
+ DisplayToastPosition,
6
+ DisplayToastAlignment,
7
+ } from "./types/components";
2
8
 
3
9
  export default defineAppConfig({
4
10
  srcdev: {
@@ -40,5 +46,8 @@ export default defineAppConfig({
40
46
  theme: undefined as SemanticTheme | undefined,
41
47
  closeIcon: "bitcoin-icons:cross-filled",
42
48
  },
49
+ cookieConsentBanner: {
50
+ theme: "info" as SemanticTheme,
51
+ },
43
52
  },
44
53
  });
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ class="privacy-notice-banner"
5
+ :class="[{ closed: status !== 'unset' }, elementClasses]"
6
+ :data-theme="resolved.theme"
7
+ data-test-id="privacy-notice-banner"
8
+ >
9
+ <div class="privacy-notice-banner-inner" role="region" :aria-label="ariaLabel">
10
+ <div class="privacy-notice-banner-message">
11
+ <slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
12
+ </div>
13
+ <div class="privacy-notice-banner-actions">
14
+ <button
15
+ type="button"
16
+ class="privacy-notice-banner-reject"
17
+ data-test-id="privacy-notice-banner-reject"
18
+ @click="rejectAll()"
19
+ >
20
+ <slot name="rejectLabel">Reject</slot>
21
+ </button>
22
+ <button
23
+ type="button"
24
+ class="privacy-notice-banner-accept"
25
+ data-test-id="privacy-notice-banner-accept"
26
+ @click="acceptAll()"
27
+ >
28
+ <slot name="acceptLabel">Accept</slot>
29
+ </button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </Teleport>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { CookieConsentBannerProps } from "../../../types/components";
38
+
39
+ const props = withDefaults(defineProps<CookieConsentBannerProps>(), {
40
+ theme: undefined,
41
+ styleClassPassthrough: () => [],
42
+ });
43
+
44
+ const appConfig = useAppConfig();
45
+
46
+ const resolved = computed(() => {
47
+ const config = appConfig.srcdev?.cookieConsentBanner;
48
+ return {
49
+ theme: props.theme ?? config?.theme ?? "info",
50
+ } as const;
51
+ });
52
+
53
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
54
+ const { status, acceptAll, rejectAll } = useCookieConsent();
55
+ const ariaLabel = "Cookie consent";
56
+ </script>
57
+
58
+ <style lang="css">
59
+ @layer components {
60
+ .privacy-notice-banner {
61
+ /* Matches DisplayToastProvider/DisplayDialog's z-index convention so
62
+ this clears ordinary page chrome (and a consumer's sticky header) but
63
+ still sits below an active modal dialog if one somehow overlaps. */
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
+
72
+ position: fixed;
73
+ inset-inline: var(--_gutter);
74
+ inset-block-end: var(--_gutter);
75
+ z-index: var(--_z-index);
76
+ margin-inline: auto;
77
+ max-width: var(--_max-width);
78
+
79
+ display: grid;
80
+ grid-template-rows: 1fr;
81
+ opacity: 1;
82
+ transition: all var(--_transition-duration) ease-in-out;
83
+
84
+ &.closed {
85
+ grid-template-rows: 0fr;
86
+ opacity: 0;
87
+ pointer-events: none;
88
+ }
89
+
90
+ .privacy-notice-banner-inner {
91
+ overflow: hidden;
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ gap: 1.2rem;
96
+ padding: 1.6rem;
97
+ border-radius: var(--_border-radius);
98
+ border: var(--_border);
99
+ background-color: var(--_background);
100
+
101
+ &[aria-label] {
102
+ border-block-start: 0.2rem solid var(--theme-accent);
103
+ }
104
+ }
105
+
106
+ .privacy-notice-banner-message {
107
+ flex: 1 1 24rem;
108
+ }
109
+
110
+ .privacy-notice-banner-actions {
111
+ display: flex;
112
+ gap: 0.8rem;
113
+ margin-inline-start: auto;
114
+ }
115
+
116
+ .privacy-notice-banner-reject,
117
+ .privacy-notice-banner-accept {
118
+ padding: 0.8rem 1.6rem;
119
+ border-radius: 0.4rem;
120
+ border: 0.1rem solid transparent;
121
+ cursor: pointer;
122
+ transition:
123
+ border-color var(--_transition-duration),
124
+ background-color var(--_transition-duration);
125
+ }
126
+
127
+ .privacy-notice-banner-reject {
128
+ background-color: transparent;
129
+ border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
130
+
131
+ &:hover,
132
+ &:focus-visible {
133
+ border-color: var(--theme-accent);
134
+ }
135
+ }
136
+
137
+ .privacy-notice-banner-accept {
138
+ background-color: var(--theme-accent);
139
+ color: light-dark(var(--slate-00), var(--slate-12));
140
+
141
+ &:hover,
142
+ &:focus-visible {
143
+ opacity: 0.9;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ </style>
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import StorybookComponent from "../CookieConsentBanner.vue";
3
+
4
+ export default {
5
+ title: "Atoms/CookieConsentBanner",
6
+ component: StorybookComponent,
7
+ argTypes: {
8
+ theme: {
9
+ control: { type: "inline-radio" },
10
+ options: ["info", "success", "warning", "error"],
11
+ description: "Semantic theme for the banner's accent border/accept button",
12
+ table: { category: "Appearance" },
13
+ },
14
+ styleClassPassthrough: {
15
+ control: { type: "object" },
16
+ description: "Extra classes applied to the banner root",
17
+ table: { category: "Styling" },
18
+ },
19
+ },
20
+ args: {
21
+ theme: "info",
22
+ styleClassPassthrough: [],
23
+ },
24
+ parameters: {
25
+ docs: {
26
+ description: {
27
+ // Drives itself from the real useCookieConsent()/useCookie() state, so
28
+ // once accepted/rejected in this browser it stays hidden across story
29
+ // reloads. Each story below has "Show banner again" (resets state
30
+ // in-place) and "Delete cookie" (clears document.cookie directly, for
31
+ // verifying the cookie itself is actually gone, e.g. in devtools).
32
+ component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
33
+ },
34
+ },
35
+ },
36
+ } as Meta<typeof StorybookComponent>;
37
+
38
+ // Resets the same "privacy-notice-consent" cookie useCookieConsent() reads, so the
39
+ // banner's status goes back to "unset" and reappears without a page reload
40
+ // (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
41
+ // itself reads).
42
+ function useResetConsent() {
43
+ const consentCookie = useCookie<"granted" | "denied" | null>("privacy-notice-consent");
44
+ const showAgain = () => {
45
+ consentCookie.value = null;
46
+ };
47
+ // Belt-and-braces alongside showAgain(): clears the actual browser cookie
48
+ // rather than just the in-memory ref, so devtools/Application tab reflects
49
+ // it too, not only the story's live re-render.
50
+ const deleteCookie = () => {
51
+ document.cookie = "privacy-notice-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
52
+ consentCookie.value = null;
53
+ };
54
+ return { showAgain, deleteCookie };
55
+ }
56
+
57
+ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
58
+ components: { StorybookComponent },
59
+ setup() {
60
+ const { showAgain, deleteCookie } = useResetConsent();
61
+ return { args, showAgain, deleteCookie };
62
+ },
63
+ template: `
64
+ <div style="padding: 2rem; min-height: 240px; position: relative;">
65
+ <button type="button" @click="showAgain">Show banner again</button>
66
+ <button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
67
+ <StorybookComponent :theme="args.theme" :style-class-passthrough="args.styleClassPassthrough">
68
+ <template #message>This site uses cookies for analytics. You can accept or reject them.</template>
69
+ </StorybookComponent>
70
+ </div>
71
+ `,
72
+ });
73
+
74
+ export const Default = Template.bind({});
75
+
76
+ export const CustomCopy: StoryFn<typeof StorybookComponent> = (args) => ({
77
+ components: { StorybookComponent },
78
+ setup() {
79
+ const { showAgain, deleteCookie } = useResetConsent();
80
+ return { args, showAgain, deleteCookie };
81
+ },
82
+ template: `
83
+ <div style="padding: 2rem; min-height: 240px; position: relative;">
84
+ <button type="button" @click="showAgain">Show banner again</button>
85
+ <button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
86
+ <StorybookComponent :theme="args.theme">
87
+ <template #message>We use cookies to understand traffic to this site. No personal data is sold.</template>
88
+ <template #acceptLabel>Allow cookies</template>
89
+ <template #rejectLabel>No thanks</template>
90
+ </StorybookComponent>
91
+ </div>
92
+ `,
93
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { ref } from "vue";
3
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+ import CookieConsentBanner from "../CookieConsentBanner.vue";
5
+
6
+ const { useAppConfigMock } = vi.hoisted(() => ({
7
+ useAppConfigMock: vi.fn(() => ({ srcdev: undefined as Record<string, unknown> | undefined, icon: {} as object })),
8
+ }));
9
+
10
+ const statusRef = ref<"unset" | "granted" | "denied">("unset");
11
+ const acceptAllSpy = vi.fn(() => {
12
+ statusRef.value = "granted";
13
+ });
14
+ const rejectAllSpy = vi.fn(() => {
15
+ statusRef.value = "denied";
16
+ });
17
+
18
+ mockNuxtImport("useAppConfig", () => useAppConfigMock);
19
+ mockNuxtImport("useCookieConsent", () => () => ({
20
+ status: statusRef,
21
+ acceptAll: acceptAllSpy,
22
+ rejectAll: rejectAllSpy,
23
+ }));
24
+
25
+ function banner() {
26
+ return document.querySelector(".privacy-notice-banner");
27
+ }
28
+
29
+ function acceptButton() {
30
+ return document.querySelector("[data-test-id='privacy-notice-banner-accept']") as HTMLElement;
31
+ }
32
+
33
+ function rejectButton() {
34
+ return document.querySelector("[data-test-id='privacy-notice-banner-reject']") as HTMLElement;
35
+ }
36
+
37
+ describe("CookieConsentBanner", () => {
38
+ beforeEach(() => {
39
+ statusRef.value = "unset";
40
+ acceptAllSpy.mockClear();
41
+ rejectAllSpy.mockClear();
42
+ });
43
+
44
+ afterEach(() => {
45
+ document.body.innerHTML = "";
46
+ });
47
+
48
+ it("mounts without error", async () => {
49
+ const w = await mountSuspended(CookieConsentBanner);
50
+ expect(w.vm).toBeTruthy();
51
+ });
52
+
53
+ it("renders the banner, teleported to body", async () => {
54
+ await mountSuspended(CookieConsentBanner);
55
+ expect(banner()).not.toBeNull();
56
+ });
57
+
58
+ it("is visible (no closed class) while status is 'unset'", async () => {
59
+ await mountSuspended(CookieConsentBanner);
60
+ expect(banner()!.classList).not.toContain("closed");
61
+ });
62
+
63
+ it("adds the closed class once status is no longer 'unset'", async () => {
64
+ statusRef.value = "granted";
65
+ await mountSuspended(CookieConsentBanner);
66
+ expect(banner()!.classList).toContain("closed");
67
+ });
68
+
69
+ it("defaults to data-theme='info'", async () => {
70
+ await mountSuspended(CookieConsentBanner);
71
+ expect(banner()!.getAttribute("data-theme")).toBe("info");
72
+ });
73
+
74
+ it("uses app.config theme when no prop is supplied", async () => {
75
+ useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "success" } } });
76
+ await mountSuspended(CookieConsentBanner);
77
+ expect(banner()!.getAttribute("data-theme")).toBe("success");
78
+ });
79
+
80
+ it("explicit theme prop takes precedence over app.config", async () => {
81
+ useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "warning" } } });
82
+ await mountSuspended(CookieConsentBanner, { props: { theme: "error" } });
83
+ expect(banner()!.getAttribute("data-theme")).toBe("error");
84
+ });
85
+
86
+ it("renders default message copy", async () => {
87
+ await mountSuspended(CookieConsentBanner);
88
+ expect(banner()!.textContent).toContain("cookies");
89
+ });
90
+
91
+ it("renders message slot content", async () => {
92
+ await mountSuspended(CookieConsentBanner, { slots: { message: "Custom cookie copy" } });
93
+ expect(banner()!.textContent).toContain("Custom cookie copy");
94
+ });
95
+
96
+ it("renders acceptLabel/rejectLabel slot content", async () => {
97
+ await mountSuspended(CookieConsentBanner, { slots: { acceptLabel: "Yes please", rejectLabel: "No thanks" } });
98
+ expect(acceptButton().textContent).toContain("Yes please");
99
+ expect(rejectButton().textContent).toContain("No thanks");
100
+ });
101
+
102
+ it("calls acceptAll() when the accept button is clicked", async () => {
103
+ await mountSuspended(CookieConsentBanner);
104
+ acceptButton().click();
105
+ expect(acceptAllSpy).toHaveBeenCalledOnce();
106
+ });
107
+
108
+ it("calls rejectAll() when the reject button is clicked", async () => {
109
+ await mountSuspended(CookieConsentBanner);
110
+ rejectButton().click();
111
+ expect(rejectAllSpy).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ it("applies a styleClassPassthrough class to the root", async () => {
115
+ await mountSuspended(CookieConsentBanner, { props: { styleClassPassthrough: "outlined" } });
116
+ expect(banner()!.classList).toContain("outlined");
117
+ });
118
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { ref } from "vue";
3
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+
5
+ // useCookieConsent creates its useScriptTriggerConsent() gate once at module
6
+ // scope (mirrors @nuxt/scripts' own documented pattern), so both mocks must
7
+ // be in place before the module under test is first imported. vi.hoisted's
8
+ // factory runs before any import, so it can only hold vi.fn() spies — real
9
+ // Vue refs are built below, after imports, and captured by the mock
10
+ // factories' closures instead.
11
+ const { acceptSpy, revokeSpy } = vi.hoisted(() => ({
12
+ acceptSpy: vi.fn(),
13
+ revokeSpy: vi.fn(),
14
+ }));
15
+
16
+ const consentedRef = ref(false);
17
+ const cookieRef = ref<"granted" | "denied" | null>(null);
18
+
19
+ mockNuxtImport("useScriptTriggerConsent", () => () => ({
20
+ consented: consentedRef,
21
+ accept: () => {
22
+ consentedRef.value = true;
23
+ acceptSpy();
24
+ },
25
+ revoke: () => {
26
+ consentedRef.value = false;
27
+ revokeSpy();
28
+ },
29
+ }));
30
+
31
+ mockNuxtImport("useCookie", () => () => cookieRef);
32
+
33
+ const { useCookieConsent } = await import("../useCookieConsent");
34
+
35
+ describe("useCookieConsent", () => {
36
+ beforeEach(() => {
37
+ cookieRef.value = null;
38
+ consentedRef.value = false;
39
+ acceptSpy.mockClear();
40
+ revokeSpy.mockClear();
41
+ });
42
+
43
+ it("reports status='unset' when no cookie has been stored", () => {
44
+ const { status } = useCookieConsent();
45
+ expect(status.value).toBe("unset");
46
+ });
47
+
48
+ it("reports status='granted' when the cookie already says granted", () => {
49
+ cookieRef.value = "granted";
50
+ const { status } = useCookieConsent();
51
+ expect(status.value).toBe("granted");
52
+ });
53
+
54
+ it("reports status='denied' when the cookie already says denied", () => {
55
+ cookieRef.value = "denied";
56
+ const { status } = useCookieConsent();
57
+ expect(status.value).toBe("denied");
58
+ });
59
+
60
+ it("restores a prior granted decision into the trigger on init", () => {
61
+ cookieRef.value = "granted";
62
+ useCookieConsent();
63
+ expect(acceptSpy).toHaveBeenCalled();
64
+ });
65
+
66
+ it("acceptAll() stores 'granted' and accepts the trigger", () => {
67
+ const { acceptAll, status } = useCookieConsent();
68
+ acceptAll();
69
+ expect(cookieRef.value).toBe("granted");
70
+ expect(status.value).toBe("granted");
71
+ expect(acceptSpy).toHaveBeenCalled();
72
+ });
73
+
74
+ it("rejectAll() stores 'denied' and revokes the trigger", () => {
75
+ const { rejectAll, status } = useCookieConsent();
76
+ rejectAll();
77
+ expect(cookieRef.value).toBe("denied");
78
+ expect(status.value).toBe("denied");
79
+ expect(revokeSpy).toHaveBeenCalled();
80
+ });
81
+
82
+ it("exposes the raw trigger for useGoogleAnalytics to consume", () => {
83
+ const { trigger } = useCookieConsent();
84
+ expect(trigger).toBeTruthy();
85
+ expect(typeof trigger.accept).toBe("function");
86
+ });
87
+ });
@@ -0,0 +1,54 @@
1
+ import type { CookieConsentStatus } from "~/types/components";
2
+
3
+ // Singleton, same pattern @nuxt/scripts itself documents for
4
+ // useScriptTriggerConsent (a single shared gate for the app's lifetime, not a
5
+ // fresh instance per call-site). This is what useGoogleAnalytics()'s
6
+ // `trigger` option is wired to.
7
+ //
8
+ // Built lazily on first call rather than at module scope — module-scope
9
+ // evaluation runs the instant this file is imported, which can happen before
10
+ // Nuxt's app/@nuxt/scripts context is ready in some environments (observed in
11
+ // Storybook), silently breaking the composable.
12
+ let consentTrigger: ReturnType<typeof useScriptTriggerConsent> | undefined;
13
+
14
+ export function useCookieConsent() {
15
+ consentTrigger ??= useScriptTriggerConsent();
16
+ const trigger = consentTrigger;
17
+
18
+ // Persists the decision across visits. Read fresh on every call so this
19
+ // stays request-safe during SSR (Nuxt dedupes useCookie() by key within a
20
+ // request, so this is cheap to call repeatedly).
21
+ const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
22
+ maxAge: 60 * 60 * 24 * 365,
23
+ sameSite: "lax",
24
+ default: () => null,
25
+ });
26
+
27
+ // Restore a prior "granted" decision into the trigger (e.g. after a full
28
+ // page reload, where the in-memory trigger resets but the cookie doesn't).
29
+ if (stored.value === "granted" && !trigger.consented.value) {
30
+ trigger.accept();
31
+ }
32
+
33
+ const status = computed<CookieConsentStatus>(() => stored.value ?? "unset");
34
+
35
+ const acceptAll = () => {
36
+ stored.value = "granted";
37
+ trigger.accept();
38
+ };
39
+
40
+ const rejectAll = () => {
41
+ stored.value = "denied";
42
+ trigger.revoke();
43
+ };
44
+
45
+ return {
46
+ status,
47
+ acceptAll,
48
+ rejectAll,
49
+ // Internal API: pass straight into useScriptGoogleAnalytics's
50
+ // scriptOptions.trigger (see useGoogleAnalytics()). Not for consuming
51
+ // app code to read/mutate directly — use status/acceptAll/rejectAll.
52
+ trigger,
53
+ };
54
+ }
@@ -0,0 +1,26 @@
1
+ // Call once from the consuming app's layout/app.vue. Loading is gated behind
2
+ // useCookieConsent() — the gtag.js script only actually fetches once the
3
+ // visitor accepts, and Google Consent Mode v2's default state starts denied
4
+ // so no analytics cookie is set before that.
5
+ export const useGoogleAnalytics = () => {
6
+ const config = useRuntimeConfig();
7
+ const id = config.public.googleAnalytics?.id;
8
+
9
+ if (!id) {
10
+ console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
11
+ return;
12
+ }
13
+
14
+ const { trigger } = useCookieConsent();
15
+
16
+ useScriptGoogleAnalytics({
17
+ id,
18
+ scriptOptions: { trigger },
19
+ defaultConsent: {
20
+ ad_storage: "denied",
21
+ ad_user_data: "denied",
22
+ ad_personalization: "denied",
23
+ analytics_storage: "denied",
24
+ },
25
+ });
26
+ };
@@ -0,0 +1,8 @@
1
+ import type { SemanticTheme } from "./semantic-theme.d"
2
+
3
+ export type CookieConsentStatus = "unset" | "granted" | "denied"
4
+
5
+ export interface CookieConsentBannerProps {
6
+ theme?: SemanticTheme
7
+ styleClassPassthrough?: string | string[]
8
+ }
@@ -15,3 +15,4 @@ export * from "./navigation-horizontal.d"
15
15
  export * from "./social-icons-list.d"
16
16
  export * from "./content-docs.d"
17
17
  export * from "./breadcrumb"
18
+ export * from "./cookie-consent-banner.d"
package/nuxt.config.ts CHANGED
@@ -26,6 +26,11 @@ export default defineNuxtConfig({
26
26
  colourScheme: {
27
27
  enabled: true,
28
28
  },
29
+ // Consumer apps set NUXT_PUBLIC_GOOGLE_ANALYTICS_ID to their GA4 measurement ID
30
+ // (G-XXXXXXXXXX) to enable useGoogleAnalytics(); left empty it no-ops.
31
+ googleAnalytics: {
32
+ id: "",
33
+ },
29
34
  },
30
35
  },
31
36
  css: ["./app/assets/styles/main.css"],
@@ -36,6 +41,7 @@ export default defineNuxtConfig({
36
41
  "@nuxt/icon",
37
42
  ...(process.env.STORYBOOK ? [] : ["@nuxt/fonts"]),
38
43
  "@nuxt/image",
44
+ "@nuxt/scripts",
39
45
  "@pinia/nuxt",
40
46
  "@vueuse/motion/nuxt",
41
47
  "pinia-plugin-persistedstate/nuxt",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.3.13",
4
+ "version": "9.4.1",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -137,6 +137,7 @@
137
137
  "@nuxt/fonts": "0.14.0",
138
138
  "@nuxt/icon": "2.5.0",
139
139
  "@nuxt/image": "2.1.0",
140
+ "@nuxt/scripts": "1.3.0",
140
141
  "@oddbird/css-anchor-positioning": "0.10.2",
141
142
  "@pinia/nuxt": "1.0.2",
142
143
  "@vueuse/motion": "3.0.3",